Compare commits

..
Author SHA1 Message Date
security-bot 0e12e90509 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757
Updates managed by image-automation-controller.
2026-10-23 08:02:02 +00:00
k.renner 613e1da011 Merge pull request 'fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL' (#1430) from fix/gate-embedded-browser-host into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1430
2026-10-23 07:57:11 +00:00
claude-botandClaude Opus 5.5 6998845854 fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The embedded outpost builds the sign-in redirect from authentik_host, not authentik_host_browser. Setting it to the in-cluster Service sent every gate's login to http://authentik-server.authentik.svc.cluster.local/. authentik_host is now https://auth.renner.uno/, Authentik's default for the embedded outpost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:55:33 +02:00
security-bot 430c710fd0 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0748
Updates managed by image-automation-controller.
2026-10-23 07:51:29 +00:00
k.renner 6accd1d3cd Merge pull request 'feat(authentik-gate): Traefik ForwardAuth, gate config on the Ingress' (#1429) from feat/authentik-gate-forwardauth into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1429
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-23 07:48:22 +00:00
claude-botandClaude Opus 5.5 6178063299 fix(authentik-gate-sync): verify the embedded outpost match, refuse shared hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The embedded outpost is matched on the client too, so a filter the server ignored cannot select the LDAP outpost. A host named by two gated Ingresses is a GateError: the outpost finds providers by host, so it would serve one gate for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:44:34 +02:00
claude-botandClaude Opus 5.5 0b7a2b6c46 feat(authentik-gate): gate config lives on the Ingress itself
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The gate is now declared on the app's own traefik Ingress: naming the
static Middleware authentik/gate (ForwardAuth against the embedded
outpost) in router.middlewares opts in, and its auth.renner.uno/*
annotations configure it. No generated route Ingress, ExternalName
Service or per-gate Middleware; the IngressClass authentik-gate is gone.

authentik-gate-sync keeps building the Authentik side (forward_single
provider on the embedded outpost, application, group policy) and the
backend CNP admitting Traefik, and sweeps the objects it generated
before. Traefik publishes the Ingress status itself.

The outpost's own paths (/outpost.goauthentik.io/) are one static
IngressRoute above the app routes. The embedded outpost answers 404 for
a host without a provider (checked live: auth/traefik with
X-Forwarded-Host no-such-gate.renner.uno), so a gate without its
provider stays closed.

docs.renner.uno points at paperless:80 behind redirect + gate (Paperless
Users) instead of the removed paperless-gate Service. Drops __pycache__
committed by mistake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:40:27 +02:00
claude-botandClaude Opus 5.5 772311c574 feat(authentik-gate): Traefik ForwardAuth on the embedded outpost
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Each gate was a single-replica proxy outpost in series with Traefik, so
any outpost restart, drain or node loss took its app down. Gates now
run as Traefik ForwardAuth against Authentik's embedded outpost inside
authentik-server: the request path is Traefik -> app, and the only auth
dependency is authentik-server, which every login already needs.

Per gated Ingress the controller now creates a forward_single provider
attached to the embedded outpost, a ForwardAuth Middleware first in the
route's chain, a /outpost.goauthentik.io route to authentik-server, and
a CNP admitting Traefik to the backend pods. It removes the per-gate
outposts and their egress CNPs.

Identity headers: authResponseHeadersRegex makes Traefik delete every
client-sent X-authentik-* header before copying the outpost's answer
(traefik pkg/middlewares/auth/forward.go), so a gated backend only ever
sees identity Authentik issued. Traefik answers 500 if the outpost is
unreachable and drops a router whose Middleware is missing: fail closed.

The blueprint no longer declares the embedded outpost: an entry must
carry providers, and re-applying [] would detach every gate. The
controller owns its providers and authentik_host/_browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:16:30 +02:00
security-bot 7bf1681c2d chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23
Updates managed by image-automation-controller.
2026-10-23 05:53:33 +00:00
k.renner c9cd0783df Merge pull request 'fix(hermes): gitea-webhook-sync covers every konairius repo via team membership' (#1413) from fix/gitea-webhook-sync-org-scope into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1413
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-23 05:34:08 +00:00
k.renner 7e57b42316 Merge pull request 'docs: drop references to the deleted *.renner.uno wildcard' (#1428) from docs/drop-wildcard-references into main
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-proxy Pipeline was canceled
Reviewed-on: #1428
2026-10-23 05:33:31 +00:00
security-bot 9afb56536c chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.11.0
Updates managed by image-automation-controller.
2026-10-23 00:49:33 +00:00
security-bot 23c089c8b7 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.4
Updates managed by image-automation-controller.
2026-10-22 22:49:23 +00:00
hermes-bot 08004a508a Merge remote-tracking branch 'origin/main' into fix/gitea-webhook-sync-org-scope
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
# Conflicts:
#	kubernetes/apps/hermes/skills/gitea-issues/SKILL.md
2026-10-22 06:01:33 +00:00
hermes-bot 3d3510b7bd fix(hermes): list hermes-bot's orgs via the admin route the PAT's scopes cover
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot: /users/{u}/orgs requires the user and organization scope
categories (Gitea 1.27.3 api.go, HasScope needs all), and the sync's PAT
has write:admin,write:organization,write:issue,read:repository, no
read:user. /admin/users/{u}/orgs needs only the admin category and lists
private orgs for a site admin.

Also: drop paged()'s dead /repos/search unwrapping, test the team-mode
fallback of team_writes_issues, and say in SKILL.md that direct-collaborator
and user repos still get cards via the system hook.
2026-10-21 07:47:13 +00:00
hermes-bot bd9f0cb18d fix(hermes): gitea-webhook-sync covers every konairius repo via team membership
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The sync reached only konairius/homelab ("repos: 1 with issues writable by
hermes-bot") although hermes-bot has write on all 10 org repos through the
org team Bots (includes_all_repositories, repo.issues write).

Cause: GET /repos/{o}/{r}/collaborators/{user}/permission (Gitea 1.27.3
GetRepoPermissions) returns permission.AccessMode, the repo-wide mode from
the collaborator/access level. Rights from a unit-based team land only in
the per-unit modes and are not part of that field, so it said "none" for
the 9 repos where hermes-bot is not a direct collaborator (checked live:
homelab "write", shelly-manager and ical-proxy "none", while
/orgs/konairius/repos reports push: true for all 10).

Scope is now derived from org membership: orgs of hermes-bot
(/users/hermes-bot/orgs) -> teams granting repo.issues write that it is a
member of -> all org repos (includes_all_repositories) or the team's repos,
non-archived with issues on. User-owned repos are out of scope, so labels
are org labels only and the repo-label branch is gone.

Tests: team scope, read-only team skipped, selected-repo team, user repos
out of scope, failed team lookups, org-labels-only. Live read-only probe
with hermes-bot's token: 10 repos, label target konairius.
2026-10-21 07:40:33 +00:00
43 changed files with 502 additions and 455 deletions
+102 -193
View File
@@ -1,34 +1,26 @@
#!/usr/bin/env python3
"""authentik-gate-sync: an ingress class that puts an Authentik proxy gate in front of an app.
"""authentik-gate-sync: the Authentik side of the gate Middleware.
Source of truth: every Ingress with spec.ingressClassName "authentik-gate". Traefik
ignores that class (its IngressClass names another controller), so a source
Ingress is never served directly. For each one this controller builds:
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
embedded outpost in authentik-server; kubernetes/apps/authentik/gate.yaml). The
gate's configuration lives on that Ingress; for each one this controller builds:
Authentik proxy provider + application + outpost (+ group-membership
expression policy bound to the application).
Authentik's own Kubernetes integration then runs the outpost as
ak-outpost-<gate> in the authentik namespace.
app ns ExternalName Service <ingress>-gate -> the outpost Service,
Traefik Ingress <ingress>-gate: the source's hosts, paths, TLS,
labels and annotations, backend swapped for the gate,
CNP <ingress>-gate: the backend pods admit this outpost.
authentik CNP gate-<gate>: the outpost may reach only those backend pods.
Authentik forward_single proxy provider for the Ingress's host +
application (+ group-membership expression policy bound to it),
the provider attached to the embedded outpost.
app ns CNP <ingress>-gate: the backend pods admit Traefik.
Like any ingress controller it publishes the source's status.loadBalancer: the
address Traefik reports for the route, or none while the gate has no route.
The source Ingress's backend is the real upstream; it becomes the provider's
internal host and its host the external host. Annotations (all optional):
Annotations (all optional):
auth.renner.uno/group group whose members may pass; none = any user
auth.renner.uno/login-flow authentication flow slug; none = the brand's
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
Fail closed: the Traefik Ingress is applied last and only after the Authentik
side reconciled, and is removed when that fails (e.g. the named group is gone),
so there is never a route to an unguarded backend.
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.
Ownership: Kubernetes objects carry app.kubernetes.io/managed-by; Authentik
applications carry meta_publisher. Anything without the marker is never touched.
@@ -48,10 +40,8 @@ LOG = logging.getLogger("authentik-gate-sync")
AK_URL = os.environ.get("AK_URL", "http://authentik-server.authentik.svc.cluster.local").rstrip("/")
AK_TOKEN_FILE = os.environ.get("AK_TOKEN_FILE", "/var/run/secrets/authentik-gate-sync/token")
INGRESS_CLASS = os.environ.get("INGRESS_CLASS", "authentik-gate")
TARGET_CLASS = os.environ.get("TARGET_INGRESS_CLASS", "traefik")
OUTPOST_NS = os.environ.get("OUTPOST_NAMESPACE", "authentik")
SERVICE_CONNECTION = os.environ.get("SERVICE_CONNECTION", "Local Kubernetes Cluster")
GATE_MIDDLEWARE = os.environ.get("GATE_MIDDLEWARE", "authentik-gate@kubernetescrd")
INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provider-authorization-implicit-consent")
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
@@ -63,14 +53,9 @@ ANN = "auth.renner.uno/"
MANAGER = "authentik-gate-sync"
MANAGED_BY = {"app.kubernetes.io/managed-by": MANAGER}
GATE_LABEL = "auth.renner.uno/gate"
OUTPOST_PORT = 9000
# Never copied from the source onto the generated Ingress: cert-manager would
# issue a second Certificate for the same secret, Flux would treat it as its own,
# and the gate annotations mean nothing to Traefik.
DROP_LABEL_PREFIXES = ("kustomize.toolkit.fluxcd.io/", "app.kubernetes.io/managed-by")
DROP_ANNOTATION_PREFIXES = (ANN, "cert-manager.io/", "acme.cert-manager.io/",
"kubectl.kubernetes.io/last-applied-configuration")
EMBEDDED_OUTPOST = "goauthentik.io/outposts/embedded"
TRAEFIK = {"k8s:io.kubernetes.pod.namespace": INGRESS_NS, "app.kubernetes.io/name": "traefik"}
MIDDLEWARES_ANN = "traefik.ingress.kubernetes.io/router.middlewares"
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
@@ -114,7 +99,7 @@ def http(method, url, body=None, headers=None, cafile=None, content_type="applic
def gate_name(ing):
"""<ns>-<ingress>, truncated, plus a hash of both: unambiguous (a-b/c vs a/b-c)
and short enough for label values and ak-outpost-<name> (63)."""
and short enough for label values (63)."""
ns, name = ing["metadata"]["namespace"], ing["metadata"]["name"]
digest = hashlib.sha256(f"{ns}/{name}".encode()).hexdigest()[:6]
return f"{ns}-{name}"[:45].rstrip("-") + "-" + digest
@@ -126,6 +111,24 @@ def group_expression(group):
return f"return ak_is_group_member(request.user, name={group!r})"
def is_gated(ing):
chain = ((ing.get("metadata") or {}).get("annotations") or {}).get(MIDDLEWARES_ANN, "")
return GATE_MIDDLEWARE in (m.strip() for m in chain.split(","))
def shared_hosts(sources):
"""Hosts named by more than one gated Ingress. The outpost finds the provider
by host, so such a host would get one gate for several Ingresses."""
seen, shared = set(), set()
for ing in sources:
for r in ing.get("spec", {}).get("rules") or []:
host = r.get("host")
if host in seen:
shared.add(host)
seen.add(host)
return shared
def gate_spec(ing):
"""Translate a source Ingress into the gate it asks for. Raises GateError."""
meta = ing["metadata"]
@@ -174,63 +177,15 @@ def gate_labels(gate):
return {**MANAGED_BY, GATE_LABEL: gate["name"]}
def outpost_service(gate):
return f"ak-outpost-{gate['name']}"
def external_name_service(gate):
return {"apiVersion": "v1", "kind": "Service",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
"spec": {"type": "ExternalName",
"externalName": f"{outpost_service(gate)}.{OUTPOST_NS}.svc.cluster.local",
"ports": [{"name": "http", "port": OUTPOST_PORT, "targetPort": OUTPOST_PORT}]}}
def route_ingress(ing, gate):
"""The Traefik Ingress: the source with its backend swapped for the gate."""
meta = ing["metadata"]
labels = {k: v for k, v in (meta.get("labels") or {}).items() if not k.startswith(DROP_LABEL_PREFIXES)}
annotations = {k: v for k, v in (meta.get("annotations") or {}).items()
if not k.startswith(DROP_ANNOTATION_PREFIXES)}
backend = {"service": {"name": f"{gate['ingress']}-gate", "port": {"number": OUTPOST_PORT}}}
rules = []
for r in ing["spec"]["rules"]:
paths = [{"path": p.get("path", "/"), "pathType": p.get("pathType", "Prefix"), "backend": backend}
for p in r["http"]["paths"]]
rules.append({"host": r["host"], "http": {"paths": paths}})
spec = {"ingressClassName": TARGET_CLASS, "rules": rules}
if ing["spec"].get("tls"):
spec["tls"] = ing["spec"]["tls"]
return {"apiVersion": "networking.k8s.io/v1", "kind": "Ingress",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": {**labels, **gate_labels(gate)}, "annotations": annotations,
"ownerReferences": owner_ref(gate)},
"spec": spec}
def outpost_selector(gate):
return {"k8s:io.kubernetes.pod.namespace": OUTPOST_NS, "goauthentik.io/outpost-name": gate["name"]}
def backend_ingress_cnp(gate, pod_selector, target_port):
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
"spec": {"endpointSelector": {"matchLabels": pod_selector},
"ingress": [{"fromEndpoints": [{"matchLabels": outpost_selector(gate)}],
"ingress": [{"fromEndpoints": [{"matchLabels": TRAEFIK}],
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
def outpost_egress_cnp(gate, pod_selector, target_port):
peer = {"k8s:io.kubernetes.pod.namespace": gate["namespace"], **pod_selector}
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
"metadata": {"name": f"gate-{gate['name']}", "namespace": OUTPOST_NS, "labels": gate_labels(gate)},
"spec": {"endpointSelector": {"matchLabels": {"goauthentik.io/outpost-name": gate["name"]}},
"egress": [{"toEndpoints": [{"matchLabels": peer}],
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
def resolve_backend(gate, service):
"""(service port number, pod selector, target port) for the gate's backend.
A named targetPort stays a name: Cilium resolves named ports against the
@@ -254,34 +209,20 @@ def resolve_backend(gate, service):
return sp["port"], selector, target
def outpost_config():
return {
"log_level": "info",
"authentik_host": AK_URL,
"authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL,
"refresh_interval": "minutes=5",
"container_image": None,
"object_naming_template": "ak-outpost-%(name)s",
"kubernetes_namespace": OUTPOST_NS,
"kubernetes_replicas": 1,
"kubernetes_service_type": "ClusterIP",
"kubernetes_disabled_components": ["ingress"],
"kubernetes_image_pull_secrets": [],
"kubernetes_ingress_annotations": {},
"kubernetes_ingress_class_name": None,
"kubernetes_ingress_secret_name": "authentik-outpost-tls",
"kubernetes_json_patches": None,
}
def embedded_config(config):
"""The embedded outpost's config with the host the gates depend on. The
embedded outpost builds the sign-in redirect from authentik_host, so it is
the public URL, which is also Authentik's default for it."""
return {**(config or {}), "authentik_host": AK_BROWSER_URL, "authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL}
def provider_body(gate, svc_port, flows):
def provider_body(gate, flows):
return {
"name": gate["name"],
"mode": "proxy",
"mode": "forward_single",
"external_host": f"{gate['scheme']}://{gate['host']}",
"internal_host": f"http://{gate['service']}.{gate['namespace']}.svc.cluster.local:{svc_port}",
"internal_host_ssl_validation": False,
"internal_host": "",
"cookie_domain": COOKIE_DOMAIN,
"access_token_validity": "hours=24",
"refresh_token_validity": "days=30",
@@ -293,18 +234,6 @@ def provider_body(gate, svc_port, flows):
}
def lb_ingress(obj):
return ((obj or {}).get("status") or {}).get("loadBalancer", {}).get("ingress") or []
def status_patch(source, addresses):
"""Merge patch publishing `addresses` as the source Ingress's load-balancer
status, as an ingress controller does; None when it already matches."""
if lb_ingress(source) == addresses:
return None
return {"status": {"loadBalancer": {"ingress": addresses or None}}}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
@@ -342,23 +271,9 @@ class K8s:
if e.code != 404:
raise
def patch_status(self, ns, name, patch):
path = f"/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses/{name}/status"
return http("PATCH", K8S_API + path, patch, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/merge-patch+json")
def delete_managed(self, obj):
try:
live = self.get(object_path(obj))
except ApiError as e:
if e.code == 404:
return
raise
if (live["metadata"].get("labels") or {}).get("app.kubernetes.io/managed-by") == MANAGER:
self.delete(obj)
LOG.info("deleted %s %s/%s", obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
# Swept by label: any object of these kinds marked as this controller's that no
# gate wants is deleted. It creates only CNPs; Ingresses and Services carrying
# the label are routes a gate must not have besides its own Ingress.
COLLECTIONS = {
"Ingress": "/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses",
"Service": "/api/v1/namespaces/{ns}/services",
@@ -425,7 +340,24 @@ def get_application(ak, slug):
return ak.get(f"core/applications/{slug}/")
def ensure_gate(ak, gate, svc_port, flows, connection):
def embedded_outpost(ak):
# Matched again here: a filter the server ignored must not select another outpost.
found = [o for o in ak.list("outposts/instances/", managed__iexact=EMBEDDED_OUTPOST)
if o.get("managed") == EMBEDDED_OUTPOST]
if len(found) != 1:
raise ApiError(f"expected one embedded outpost, found {len(found)}")
return found[0]
def ensure_embedded_config(ak):
outpost = embedded_outpost(ak)
want = embedded_config(outpost.get("config"))
if outpost.get("config") != want:
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", {"config": want})
LOG.info("embedded outpost: updated config")
def ensure_gate(ak, gate, flows):
name = gate["name"]
if gate["group"] and ak.one("core/groups/", name=gate["group"]) is None:
raise GateError(f"group {gate['group']!r} not found in Authentik")
@@ -434,7 +366,7 @@ def ensure_gate(ak, gate, svc_port, flows, connection):
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {name} exists and is not managed by {MANAGER}")
want = provider_body(gate, svc_port, flows)
want = provider_body(gate, flows)
provider = ak.one("providers/proxy/", name__iexact=name)
if provider is None:
provider = ak.req("POST", "providers/proxy/", want)
@@ -461,17 +393,22 @@ def ensure_gate(ak, gate, svc_port, flows, connection):
ensure_access(ak, gate, app["pbm_uuid"])
want_outpost = {"name": name, "type": "proxy", "service_connection": connection,
"providers": [provider["pk"]], "config": outpost_config()}
outpost = ak.one("outposts/instances/", name__iexact=name)
if outpost is None:
ak.req("POST", "outposts/instances/", want_outpost)
LOG.info("%s: created outpost", name)
elif set(outpost.get("providers") or []) - {provider["pk"]}:
raise GateError(f"outpost {name} serves providers this controller does not own")
elif drift(outpost, want_outpost):
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", drift(outpost, want_outpost))
LOG.info("%s: updated outpost %s", name, sorted(drift(outpost, want_outpost)))
# Re-read per gate: the providers list is replaced whole.
embedded = embedded_outpost(ak)
if provider["pk"] not in (embedded.get("providers") or []):
ak.req("PATCH", f"outposts/instances/{embedded['pk']}/",
{"providers": [*(embedded.get("providers") or []), provider["pk"]]})
LOG.info("%s: attached provider to the embedded outpost", name)
remove_dedicated_outposts(ak, name, provider["pk"])
def remove_dedicated_outposts(ak, name, provider):
"""Outposts named after the gate that serve only its provider: the embedded
outpost serves every gate, so Authentik tears these and their pods down."""
for o in ak.list("outposts/instances/", name__iexact=name):
if o.get("providers") == [provider] and not o.get("managed"):
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
LOG.info("%s: removed dedicated outpost", name)
def ensure_access(ak, gate, target):
@@ -511,15 +448,12 @@ def ensure_access(ak, gate, target):
def remove_gate(ak, app):
"""Delete an owned gate. The outpost serving only its provider goes first
(Authentik tears its pods down), then the application (bindings cascade; its
provider is PROTECTed while referenced), the provider, 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")
for o in ak.list("outposts/instances/", name__iexact=name):
if o.get("providers") == [provider]:
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
remove_dedicated_outposts(ak, name, provider)
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"providers/proxy/{provider}/")
@@ -543,32 +477,15 @@ def owned_applications(ak):
# --- reconcile --------------------------------------------------------------
def publish_status(k8s, source, addresses):
"""Status is cosmetic for the gate itself: a failure is logged, never fatal."""
patch = status_patch(source, addresses)
if patch is None:
return
m = source["metadata"]
try:
k8s.patch_status(m["namespace"], m["name"], patch)
LOG.info("%s/%s: status %s", m["namespace"], m["name"],
[a.get("ip") or a.get("hostname") for a in addresses])
except Exception as e:
LOG.warning("%s/%s: publishing status: %s", m["namespace"], m["name"], e)
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
if i.get("spec", {}).get("ingressClassName") == INGRESS_CLASS]
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/")}
connection = ak.one("outposts/service_connections/kubernetes/", name=SERVICE_CONNECTION)
if connection is None:
raise ApiError(f"Kubernetes service connection {SERVICE_CONNECTION!r} not found")
connection = connection["pk"]
ensure_embedded_config(ak)
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
gates = set() # gate names whose Authentik side should exist
@@ -578,6 +495,8 @@ def reconcile():
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
try:
gate = gate_spec(ing)
if gate["host"] in shared:
raise GateError(f"host {gate['host']} is gated by more than one Ingress")
for slug in filter(None, (gate["authorization_flow"], gate["login_flow"], INVALIDATION_FLOW)):
if slug not in flows:
raise GateError(f"flow {slug!r} not found in Authentik")
@@ -588,28 +507,18 @@ def reconcile():
if e.code == 404:
raise GateError(f"backend Service {gate['service']} does not exist") from None
raise
svc_port, pods, target_port = resolve_backend(gate, service)
_, pods, target_port = resolve_backend(gate, service)
gates.add(gate["name"])
ensure_gate(ak, gate, svc_port, flows, connection)
objs = [outpost_egress_cnp(gate, pods, target_port), backend_ingress_cnp(gate, pods, target_port),
external_name_service(gate), route_ingress(ing, gate)] # the route goes last
for obj in objs:
live = k8s.apply(obj)
desired.add((obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"]))
publish_status(k8s, ing, lb_ingress(live)) # the route's address, from Traefik
ensure_gate(ak, gate, flows)
cnp = backend_ingress_cnp(gate, pods, target_port)
k8s.apply(cnp)
desired.add((cnp["kind"], cnp["metadata"]["namespace"], cnp["metadata"]["name"]))
except GateError as e:
# The Ingress cannot be gated as written: its route goes now, the
# rest with the sweep below.
# The Ingress cannot be gated as written: its provider goes with the
# sweep below, after which the outpost rejects the host.
LOG.error("%s: %s", ref, e)
failed += 1
gates.discard(gate_name(ing))
route = {"kind": "Ingress", "metadata": {"namespace": ing["metadata"]["namespace"],
"name": f'{ing["metadata"]["name"]}-gate'}}
try:
k8s.delete_managed(route)
except Exception as e: # the sweep retries
LOG.error("%s: removing route: %s", ref, e)
publish_status(k8s, ing, [])
except Exception as e:
# Transient: leave this gate exactly as the last good pass left it.
LOG.error("%s: %s", ref, e)
@@ -11,11 +11,12 @@ LLAMA = {
"cert-manager.io/cluster-issuer": "letsencrypt-cluster",
"auth.renner.uno/login-flow": "sso-authentication",
"auth.renner.uno/skip-path": r"^/(manifest\.webmanifest|sw\.js)$",
"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd",
"traefik.ingress.kubernetes.io/router.middlewares":
"authentik-gate@kubernetescrd, ingress-foo@kubernetescrd",
},
},
"spec": {
"ingressClassName": "authentik-gate",
"ingressClassName": "traefik",
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
{"path": "/", "pathType": "Prefix",
@@ -41,7 +42,7 @@ class Names(unittest.TestCase):
def test_bounded(self):
n = c.gate_name(ing("n" * 63, "i" * 253))
self.assertLessEqual(len(c.outpost_service({"name": n})), 63)
self.assertLessEqual(len(n), 63)
self.assertNotIn("--", n)
def test_group_expression_is_a_literal(self):
@@ -90,6 +91,22 @@ class GateSpec(unittest.TestCase):
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_shared_hosts(self):
other = copy.deepcopy(LLAMA)
other["metadata"]["name"] = "llama-admin"
self.assertEqual(c.shared_hosts([LLAMA, other]), {"llm.renner.uno"})
other["spec"]["rules"][0]["host"] = "admin.renner.uno"
self.assertEqual(c.shared_hosts([LLAMA, other]), set())
def test_gated_by_middleware_reference(self):
self.assertTrue(c.is_gated(LLAMA))
ing = copy.deepcopy(LLAMA)
ing["metadata"]["annotations"]["traefik.ingress.kubernetes.io/router.middlewares"] = \
"ingress-foo@kubernetescrd,authentik-gate-other@kubernetescrd"
self.assertFalse(c.is_gated(ing))
del ing["metadata"]["annotations"]
self.assertFalse(c.is_gated(ing))
class Backend(unittest.TestCase):
def test_named_target_port_stays_named(self):
@@ -126,65 +143,34 @@ class Objects(unittest.TestCase):
def setUp(self):
self.g = c.gate_spec(LLAMA)
def test_route_ingress(self):
r = c.route_ingress(LLAMA, self.g)
self.assertEqual(r["metadata"]["name"], "llama-server-gate")
self.assertEqual(r["spec"]["ingressClassName"], "traefik")
self.assertEqual(r["spec"]["tls"], LLAMA["spec"]["tls"])
backend = r["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]
self.assertEqual(backend, {"name": "llama-server-gate", "port": {"number": 9000}})
labels, ann = r["metadata"]["labels"], r["metadata"]["annotations"]
self.assertEqual(labels["dns.renner.uno/public"], "true")
self.assertNotIn("kustomize.toolkit.fluxcd.io/name", labels)
self.assertEqual(labels["app.kubernetes.io/managed-by"], c.MANAGER)
self.assertEqual(ann, {"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd"})
self.assertEqual(r["metadata"]["ownerReferences"][0]["uid"], "u-1")
def test_external_name(self):
s = c.external_name_service(self.g)
self.assertEqual(s["spec"]["externalName"], f"ak-outpost-{self.g['name']}.authentik.svc.cluster.local")
def test_cnps(self):
def test_backend_admits_traefik_only(self):
pods = {"app.kubernetes.io/name": "llama-server"}
e = c.outpost_egress_cnp(self.g, pods, 8080)
self.assertEqual(e["metadata"]["namespace"], "authentik")
self.assertEqual(e["spec"]["endpointSelector"]["matchLabels"],
{"goauthentik.io/outpost-name": self.g["name"]})
self.assertEqual(e["spec"]["egress"][0]["toEndpoints"][0]["matchLabels"],
{"k8s:io.kubernetes.pod.namespace": "ai", "app.kubernetes.io/name": "llama-server"})
i = c.backend_ingress_cnp(self.g, pods, 8080)
self.assertEqual(i["metadata"]["namespace"], "ai")
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"][0]["matchLabels"],
{"k8s:io.kubernetes.pod.namespace": "authentik",
"goauthentik.io/outpost-name": self.g["name"]})
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"],
[{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "ingress",
"app.kubernetes.io/name": "traefik"}}])
self.assertEqual(i["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "8080")
def test_provider_body(self):
flows = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au",
c.INVALIDATION_FLOW: "inv"}
p = c.provider_body(self.g, 8080, flows)
p = c.provider_body(self.g, flows)
self.assertEqual(p["mode"], "forward_single")
self.assertEqual(p["external_host"], "https://llm.renner.uno")
self.assertEqual(p["internal_host"], "http://llama-server.ai.svc.cluster.local:8080")
self.assertEqual(p["internal_host"], "")
self.assertEqual((p["authorization_flow"], p["authentication_flow"], p["invalidation_flow"]),
("az", "au", "inv"))
self.assertEqual(p["skip_path_regex"], r"^/(manifest\.webmanifest|sw\.js)$")
def test_status_mirrors_route_address(self):
addrs = [{"ip": "10.2.99.86"}, {"ip": "fd99::86"}]
route = {"status": {"loadBalancer": {"ingress": addrs}}}
self.assertEqual(c.status_patch(LLAMA, c.lb_ingress(route)),
{"status": {"loadBalancer": {"ingress": addrs}}})
synced = dict(LLAMA, status={"loadBalancer": {"ingress": addrs}})
self.assertIsNone(c.status_patch(synced, addrs))
def test_status_cleared_without_route(self):
stale = dict(LLAMA, status={"loadBalancer": {"ingress": [{"ip": "10.2.99.86"}]}})
self.assertEqual(c.status_patch(stale, []), {"status": {"loadBalancer": {"ingress": None}}})
self.assertIsNone(c.status_patch(LLAMA, []))
def test_drift(self):
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
def test_embedded_config_keeps_other_keys(self):
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
@@ -267,20 +253,69 @@ class Reconcile(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.g = c.gate_spec(LLAMA)
self.embedded = self.ak.add("outposts/instances/", {
"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
"providers": [], "config": {"log_level": "info"}})
def gate(self, group=None):
g = dict(self.g, group=group)
c.ensure_gate(self.ak, g, 8080, FLOWS, "conn")
c.ensure_gate(self.ak, g, FLOWS)
return g
def embedded_providers(self):
return self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["providers"]
def test_creates_owned_gate(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
outpost = self.ak.one("outposts/instances/", name__iexact=self.g["name"])
self.assertEqual(outpost["providers"], [app["provider"]])
self.assertEqual(self.embedded_providers(), [app["provider"]])
self.assertEqual(self.ak.list("outposts/instances/", name__iexact=self.g["name"]), [])
self.assertEqual(self.ak.list("policies/bindings/"), [])
def test_keeps_other_providers_on_embedded_outpost(self):
self.ak.db["outposts/instances/"][self.embedded["pk"]]["providers"] = [999]
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(self.embedded_providers(), [999, app["provider"]])
def test_removes_dedicated_outpost(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.ak.add("outposts/instances/", {"name": self.g["name"], "providers": [app["provider"]]})
shared = self.ak.add("outposts/instances/", {"name": self.g["name"].upper(),
"providers": [app["provider"], 999]})
self.gate()
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/", name__iexact=self.g["name"])],
[shared["pk"]])
def test_embedded_lookup_ignores_unfiltered_results(self):
ldap = self.ak.add("outposts/instances/", {"name": "LDAP Outpost", "managed": None, "providers": [7]})
unfiltered = self.ak.list
self.ak.list = lambda path, **q: unfiltered(path) # a server that ignores the filter
self.assertEqual(c.embedded_outpost(self.ak)["pk"], self.embedded["pk"])
del self.ak.db["outposts/instances/"][self.embedded["pk"]]
with self.assertRaises(c.ApiError):
c.embedded_outpost(self.ak)
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
def test_embedded_config(self):
c.ensure_embedded_config(self.ak)
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
before = len(self.ak.calls)
c.ensure_embedded_config(self.ak)
self.assertEqual(self.ak.calls[before:], [])
def test_provider_moves_from_proxy_mode(self):
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
"assigned_application_slug": None})
self.gate()
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
self.assertEqual((provider["mode"], provider["internal_host"]), ("forward_single", ""))
def test_second_pass_is_a_noop(self):
self.gate()
before = len(self.ak.calls)
@@ -329,15 +364,15 @@ class Reconcile(unittest.TestCase):
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
def test_remove_gate_and_ownership_scan(self):
self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": 1})
foreign = self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": foreign["pk"]})
self.gate()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], [self.g["name"]])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get(f"core/applications/{self.g['name']}/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/proxy/")], ["foreign"])
self.assertEqual(self.ak.list("outposts/instances/"), [])
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/")], [self.embedded["pk"]])
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
self.gate()
@@ -374,16 +374,10 @@ data:
name: lidarr-gate
state: absent
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
# No providers attached — each gate outpost serves its own
# /outpost.goauthentik.io/* path on the app host.
providers: []
# The embedded outpost is not declared here: authentik-gate-sync owns its
# providers list (every gate) and the hosts in its config, and a
# blueprint entry must carry `providers`, which would detach the gates
# on every apply.
# Shared LDAPS cert for the LDAP Outpost. Authentik's data model
# owns the cert per-LDAPProvider (no outpost-level cert field), so
+12 -23
View File
@@ -1,15 +1,9 @@
# The authentik-gate ingress class: an Ingress of this class becomes an
# Authentik proxy gate (provider, application, outpost, group binding) plus the
# Traefik Ingress, ExternalName Service and CiliumNetworkPolicies in front of
# its backend. Traefik ignores the class (it only serves classes naming
# traefik.io/ingress-controller). Controller: ci/images/authentik-gate-sync.
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: authentik-gate
spec:
controller: renner.uno/authentik-gate
---
# authentik-gate-sync: for every Ingress that names the gate Middleware
# (gate.yaml) it builds the Authentik provider (on the embedded outpost),
# application and group binding from the Ingress's auth.renner.uno/*
# annotations, and a CiliumNetworkPolicy admitting Traefik to its backend.
# Controller: ci/images/authentik-gate-sync.
#
# API token of the authentik-gate-sync service account. Generated once; the
# blueprint (gate-sync.yaml key in blueprints-configmap.yaml) sets it as the
# token's key via AUTHENTIK_GATE_SYNC_TOKEN, the controller reads the same Secret.
@@ -51,8 +45,9 @@ metadata:
labels:
app.kubernetes.io/name: authentik-gate-sync
---
# Cluster-wide: gated Ingresses live in any namespace, and so do the route
# Ingress, ExternalName Service and backend CNP generated next to them.
# Cluster-wide: gated Ingresses live in any namespace, and so do their backend
# Services and the CNP generated next to them. Delete on Ingresses and Services
# covers objects labelled as this controller's that no gate wants.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
@@ -60,14 +55,10 @@ metadata:
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# The source Ingress's load-balancer status mirrors its generated route.
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses/status"]
verbs: ["get", "patch", "update"]
verbs: ["get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
verbs: ["get", "list", "watch", "delete"]
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
@@ -114,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -125,8 +116,6 @@ spec:
value: http://authentik-server.authentik.svc.cluster.local
- name: AUTHENTIK_HOST_BROWSER
value: https://auth.renner.uno/
- name: SERVICE_CONNECTION
value: Local Kubernetes Cluster
- name: DEFAULT_AUTHORIZATION_FLOW
value: default-provider-authorization-implicit-consent
- name: INTERVAL_SECONDS
+38
View File
@@ -0,0 +1,38 @@
# The Authentik gate. An Ingress opts in by naming this Middleware in
# traefik.ingress.kubernetes.io/router.middlewares (authentik-gate@kubernetescrd);
# its auth.renner.uno/* annotations configure the gate, and authentik-gate-sync
# (gate-sync.yaml) builds the Authentik provider and the backend CNP from them.
# The embedded outpost picks the provider by Host and rejects hosts it has no
# provider for.
#
# authResponseHeadersRegex makes Traefik delete every client-sent X-authentik-*
# header before copying the outpost's, so a gated backend only sees identity
# Authentik issued.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: gate
namespace: authentik
spec:
forwardAuth:
address: http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik
authResponseHeadersRegex: "(?i)^x-authentik-"
---
# The outpost's own paths (sign-in callback, sign-out) on every host. Above the
# app routes by priority; on hosts without a gate provider the outpost rejects.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: gate-outpost
namespace: authentik
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: PathPrefix(`/outpost.goauthentik.io/`)
priority: 100000
services:
- name: authentik-server
port: 80
tls: {}
@@ -6,6 +6,7 @@ resources:
- blueprints-configmap.yaml
- helmrelease.yaml
- gate-sync.yaml
- gate.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
+1 -1
View File
@@ -211,7 +211,7 @@ data:
# Paperless sits entirely behind the Authentik gate — every path,
# including /api/, answers 302 to unauthenticated callers, so there is no
# endpoint that proves the app rather than the gate. This check is
# therefore reachability only: DNS, TLS, Traefik and the outpost. It
# therefore reachability only: DNS, TLS, Traefik and forward auth. It
# stays green if paperless itself is dead behind a live gate.
#
# Closing that blind spot needs an in-cluster probe of the paperless
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
@@ -19,8 +19,11 @@ expert's PR goes back to the card that opened it, without a manual step.
labels, PR reviews, PR comments, PR open/close) posts to
`http://hermes.hermes.svc.cluster.local:8644/webhooks/gitea-issues`.
The CronJob `gitea-webhook-sync` (namespace hermes, every 15 min) keeps that hook in
place, creates the domain labels and backfills, all only in repos where hermes-bot
has issue write (org labels for their orgs, repo labels for user-owned ones): open
place, creates the domain labels and backfills, all in every non-archived repo with
issues of the orgs hermes-bot belongs to, where an org team grants it issue write
(today: all repos of `konairius` through team `Bots`). Labels are org labels only.
User-owned repos and repos where hermes-bot is only a direct collaborator get neither
labels nor backfill; their new issues still become cards through the system hook. Backfill: open
issues without a card comment are replayed to the route as signed `opened`
deliveries, at most 10 per run, and the reviews of open hermes-bot PRs are replayed
(step 8). Its site-admin PAT lives at OpenBao `apps/gitea-webhook-sync/admin`,
@@ -7,10 +7,10 @@
active system hook carries the desired name, a new one is created first and every
other hook with the prefix is deleted afterwards.
2. Domain labels. hestia/atlas/apollo/clio/daedalus are created only where Hermes
works issues: repos with an issue tracker in which BOT_LOGIN (hermes-bot) has
issue write. They become org labels in each org owning such a repo and repo labels
in such user-owned repos (org labels do not reach those). Existing labels are left
as they are; only missing ones are created.
works issues: the non-archived org repos with an issue tracker in which BOT_LOGIN
(hermes-bot) may write issues through an org team (it labels and comments). They
are org labels of those orgs, never repo labels; user-owned repos are out of scope.
Existing labels are left as they are; only missing ones are created.
3. Backfill. Open issues of the same repos without the bridge's card comment are
replayed to the Hermes webhook route as signed `opened` deliveries, at most
BACKFILL_LIMIT per run and none started after RUN_BUDGET seconds, in random order
@@ -60,9 +60,10 @@ BACKFILL_SPACING = float(os.environ.get("BACKFILL_SPACING_SECONDS", "3"))
RUN_BUDGET = float(os.environ.get("RUN_BUDGET_SECONDS", "900"))
DELIVER_TIMEOUT = 60
CARD_MARKER = "<!-- hermes-kanban-card:"
# Labels and backfill only where this account may write issues (it labels and comments).
# Labels and backfill only in org repos this account may write issues in (it labels and comments).
BOT_LOGIN = os.environ.get("BOT_LOGIN", "hermes-bot")
BOT_WRITE = ("write", "admin", "owner")
ISSUES_UNIT = "repo.issues"
STARTED = time.monotonic()
# Review replay (step 4): reviews by these accounts on open BOT_LOGIN PRs.
REVIEW_BOT_LOGIN = os.environ.get("REVIEW_BOT_LOGIN", "review-bot")
@@ -110,13 +111,11 @@ def api(method: str, path: str, token: str, body: dict | None = None):
def paged(path: str, token: str) -> list[dict]:
"""All pages of a list endpoint; /repos/search wraps its list in {"data": [...]}."""
"""All pages of a list endpoint."""
out, page = [], 1
sep = "&" if "?" in path else "?"
while True:
batch = api("GET", f"{path}{sep}page={page}&limit=50", token) or []
if isinstance(batch, dict):
batch = batch.get("data") or []
out.extend(batch)
if len(batch) < 50:
return out
@@ -160,25 +159,56 @@ def reconcile(token: str, secret: str) -> str:
# --- 2. domain labels --------------------------------------------------------
def team_writes_issues(team: dict) -> bool:
"""Issue write from the team's unit map (Gitea >= 1.20), else its team-wide mode."""
if team.get("permission") in ("admin", "owner"):
return True
units = team.get("units_map") or {}
return (units.get(ISSUES_UNIT) if units else team.get("permission")) in BOT_WRITE
def issue_repos(token: str, errors: list[str] | None = None) -> list[dict]:
"""Repos whose issues Hermes works: issue tracker on, not archived, and hermes-bot may
write issues there (it sets the labels and comments). A site admin sees every repo.
A repo whose permission lookup fails is skipped and named in `errors`."""
out = []
for r in paged("/repos/search", token):
if not r.get("has_issues", True) or r.get("archived"):
"""Repos whose issues Hermes works: in each org BOT_LOGIN belongs to, the repos of its
teams that grant issue write (every org repo for an includes_all_repositories team),
issue tracker on, not archived. Not /collaborators/{user}/permission: that reports
the repo-wide access mode, which stays "none" when the write comes from a unit-based
team. The admin route lists private orgs too and needs only the PAT's admin scope
(/users/{u}/orgs also requires read:user). An org or team whose lookup fails is
skipped and named in `errors`."""
def failed(what: str, exc: RuntimeError) -> None:
if errors is None:
raise exc
errors.append(f"{what}: {exc}")
found: dict[str, dict] = {}
for org in paged(f"/admin/users/{q(BOT_LOGIN)}/orgs", token):
name = org.get("username") or org.get("name") or ""
if not name:
continue
owner, _, name = r["full_name"].partition("/")
try:
perm = api("GET", f"/repos/{q(owner)}/{q(name)}/collaborators/{q(BOT_LOGIN)}/permission", token)
teams = paged(f"/orgs/{q(name)}/teams", token)
except RuntimeError as exc:
if errors is None:
raise
errors.append(f"{r['full_name']}: {exc}")
failed(name, exc)
continue
if (perm or {}).get("permission") in BOT_WRITE:
out.append(r)
return out
for team in teams:
if not team_writes_issues(team):
continue
try:
try:
api("GET", f"/teams/{team['id']}/members/{q(BOT_LOGIN)}", token)
except RuntimeError as exc:
if str(exc).endswith("HTTP 404"):
continue # not a member of this team
raise
repos = paged(f"/orgs/{q(name)}/repos" if team.get("includes_all_repositories")
else f"/teams/{team['id']}/repos", token)
except RuntimeError as exc:
failed(f"{name} team {team.get('name')}", exc)
continue
for r in repos:
if r.get("has_issues", True) and not r.get("archived"):
found.setdefault(r["full_name"], r)
return [found[k] for k in sorted(found)]
def ensure_labels_at(path: str, token: str) -> list[str]:
@@ -192,18 +222,14 @@ def ensure_labels_at(path: str, token: str) -> list[str]:
def ensure_labels(token: str, repos: list[dict]) -> str:
"""Org labels in each org that owns one of `repos`, repo labels in the user-owned ones."""
orgs = {n for o in paged("/admin/orgs", token) if (n := o.get("username") or o.get("name"))}
owners = {(r.get("owner") or {}).get("login") for r in repos}
targets = [f"/orgs/{q(org)}/labels" for org in sorted(orgs & owners)]
targets += [f"/repos/{q(r['owner']['login'])}/{q(r['name'])}/labels" for r in repos
if (r.get("owner") or {}).get("login") not in orgs]
"""Org labels in each org that owns one of `repos` (all org repos); never repo labels."""
orgs = sorted({o for r in repos if (o := (r.get("owner") or {}).get("login"))})
actions = []
for path in targets:
created = ensure_labels_at(path, token)
for org in orgs:
created = ensure_labels_at(f"/orgs/{q(org)}/labels", token)
if created:
actions.append(f"{path.rsplit('/labels', 1)[0]}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(targets)} orgs/repos"
actions.append(f"/orgs/{org}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(orgs)} orgs"
# --- 3. backfill -------------------------------------------------------------
@@ -372,9 +398,9 @@ def main() -> int:
def load_repos() -> str:
errors: list[str] = []
repos.extend(issue_repos(token, errors))
msg = f"{len(repos)} with issues writable by {BOT_LOGIN}"
msg = f"{len(repos)} org repos with issues writable by {BOT_LOGIN}"
if errors: # the other repos still get labels and backfill; the Job fails
raise RuntimeError(f"{msg}; permission lookup failed, skipped: {'; '.join(errors)}")
raise RuntimeError(f"{msg}; team lookup failed, skipped: {'; '.join(errors)}")
return msg
failures = 0
@@ -701,62 +701,120 @@ def repo(full, has_issues=True, archived=False):
"has_issues": has_issues, "archived": archived}
def team(tid, name, issues="write", all_repos=True, permission="write"):
return {"id": tid, "name": name, "includes_all_repositories": all_repos,
"permission": permission, "units_map": {"repo.issues": issues, "repo.code": "write"}}
def fake_gitea(orgs, teams, members, repos, fail=()):
"""api() stand-in: orgs of the bot, teams per org, bot's team ids, repos per org or team."""
seen = []
def fake(method, path, token, body=None):
p = path.split("?")[0]
seen.append(p)
if p in fail:
raise RuntimeError(f"GET {p}: HTTP 500")
if p == "/admin/users/hermes-bot/orgs":
return [{"username": o} for o in orgs]
if p.startswith("/orgs/") and p.endswith("/teams"):
return teams.get(p.split("/")[2], [])
if p.startswith("/teams/") and "/members/" in p:
if int(p.split("/")[2]) in members:
return {"login": "hermes-bot"}
raise RuntimeError(f"GET {p}: HTTP 404")
if p.endswith("/repos"):
return repos.get(p, [])
raise AssertionError(f"unexpected {method} {p}")
return fake, seen
class DomainLabels(unittest.TestCase):
def test_labels_only_for_orgs_and_user_repos_hermes_works(self):
def test_org_labels_only_never_repo_labels(self):
calls = []
def fake(method, path, token, body=None):
calls.append((method, path, body))
if path.startswith("/admin/orgs"):
return [{"username": "konairius"}, {"username": "unrelated-org"}]
if path.startswith("/orgs/konairius/labels"):
return [{"name": "Atlas"}, {"name": "bug"}]
return []
repos = [repo("konairius/homelab"), repo("k.renner/turm1388")]
repos = [repo("konairius/homelab"), repo("konairius/shelly-manager")]
with mock.patch.object(sync, "api", side_effect=fake):
msg = sync.ensure_labels("tok", repos)
posts = [(p, b["name"]) for m, p, b in calls if m == "POST"]
self.assertEqual(sorted(n for p, n in posts if p == "/orgs/konairius/labels"),
["apollo", "clio", "daedalus", "hestia"]) # atlas exists (any case)
self.assertEqual(len([p for p, _ in posts if p == "/repos/k.renner/turm1388/labels"]), 5)
self.assertFalse([p for p, _ in posts if p.startswith("/repos/konairius/")]) # org labels reach it
self.assertFalse([c for c in calls if "unrelated-org" in c[1]]) # no repo of it is worked
self.assertFalse([c for c in calls if c[1].startswith("/repos/")]) # org labels reach them
self.assertFalse([c for c in calls if c[1].startswith("/admin/")])
self.assertIn("created", msg)
def test_repos_without_issues_archived_or_bot_write_are_skipped(self):
perms = {"a/x": "write", "a/w": "admin", "gitea_admin/_cargo-index": "read", "a/n": "none"}
seen = []
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"ok": True, "data": [
repo("a/x"), repo("a/w"), repo("gitea_admin/_cargo-index"), repo("a/n"),
repo("a/y", has_issues=False), repo("a/z", archived=True)]}
seen.append(path)
full = path.split("/repos/")[1].split("/collaborators/")[0]
return {"permission": perms[full]}
def test_team_write_covers_every_org_repo_without_collaborator(self):
# Live 2026-10-21: hermes-bot is only in team Bots (all repos, issues write);
# /collaborators/hermes-bot/permission says "none" for 9 of the 10 repos.
org_repos = [repo("konairius/homelab"), repo("konairius/shelly-manager"),
repo("konairius/ical-proxy"), repo("konairius/off", has_issues=False),
repo("konairius/old", archived=True)]
fake, seen = fake_gitea(
orgs=["konairius"],
teams={"konairius": [team(1, "Owners", "owner", permission="owner"), team(2, "Bots"),
team(5, "Reviewers", "read", permission="none")]},
members={2},
repos={"/orgs/konairius/repos": org_repos})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/w"])
self.assertIn("/repos/a/x/collaborators/hermes-bot/permission", seen)
self.assertFalse([p for p in seen if p.startswith(("/repos/a/y/", "/repos/a/z/"))])
self.assertEqual(got, ["konairius/homelab", "konairius/ical-proxy", "konairius/shelly-manager"])
self.assertFalse([p for p in seen if "/collaborators/" in p or p.startswith("/repos/")])
self.assertNotIn("/teams/5/members/hermes-bot", seen) # read-only team is not even checked
def test_failed_permission_lookup_skips_only_that_repo(self):
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"data": [repo("a/x"), repo("a/broken"), repo("a/w")]}
if path.startswith("/repos/a/broken/"):
raise RuntimeError("GET /repos/a/broken/...: HTTP 500")
return {"permission": "write"}
def test_team_with_selected_repos_and_no_issue_write(self):
fake, seen = fake_gitea(
orgs=["a", "b"],
teams={"a": [team(3, "Some", all_repos=False)],
"b": [team(4, "CodeOnly", issues="read")]},
members={3, 4},
repos={"/teams/3/repos": [repo("a/x"), repo("a/y")],
"/orgs/a/repos": [repo("a/x"), repo("a/y"), repo("a/not-in-team")],
"/orgs/b/repos": [repo("b/z")]})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/y"])
self.assertNotIn("/orgs/b/repos", seen)
def test_team_issue_write_from_units_map_or_team_mode(self):
self.assertTrue(sync.team_writes_issues(team(2, "Bots")))
self.assertFalse(sync.team_writes_issues(team(5, "Reviewers", "read", permission="none")))
self.assertTrue(sync.team_writes_issues(team(1, "Owners", "read", permission="owner")))
self.assertTrue(sync.team_writes_issues({"permission": "write"})) # no units_map
self.assertFalse(sync.team_writes_issues({"permission": "read", "units_map": {}}))
def test_user_repos_are_out_of_scope(self):
fake, seen = fake_gitea(orgs=[], teams={}, members=set(), repos={})
with mock.patch.object(sync, "api", side_effect=fake):
self.assertEqual(sync.issue_repos("tok"), [])
self.assertEqual(seen, ["/admin/users/hermes-bot/orgs"]) # no /repos/search over user repos
def test_failed_team_lookup_skips_only_that_team(self):
fake, _ = fake_gitea(
orgs=["a", "broken"],
teams={"a": [team(3, "Bots", all_repos=False), team(6, "More", all_repos=False)]},
members={3, 6},
repos={"/teams/3/repos": [repo("a/x")], "/teams/6/repos": [repo("a/w"), repo("a/x")]},
fail={"/orgs/broken/teams", "/teams/6/members/hermes-bot"})
errors = []
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok", errors)]
self.assertEqual(got, ["a/x", "a/w"])
self.assertEqual(len(errors), 1)
self.assertIn("a/broken", errors[0])
self.assertEqual(got, ["a/x"])
self.assertEqual(len(errors), 2)
self.assertIn("a team More", errors[0])
self.assertTrue(errors[1].startswith("broken:"))
def test_failed_lookup_raises_without_error_list(self):
fake, _ = fake_gitea(orgs=["broken"], teams={}, members=set(), repos={},
fail={"/orgs/broken/teams"})
with mock.patch.object(sync, "api", side_effect=fake), self.assertRaises(RuntimeError):
sync.issue_repos("tok")
class Backfill(unittest.TestCase):
@@ -3,8 +3,8 @@
# /etc/nginx/conf.d/ at start.
#
# shelly-manager signs in whoever X-authentik-username names when the peer is
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only the Authentik
# outpost may reach :8080 directly. Other clients reach this :8081 instead,
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only Traefik may
# reach :8080 directly. Other clients reach this :8081 instead,
# which drops the identity header, so every request here falls through
# to the API key (or the login cookie) and a forged header gets 401.
#
@@ -75,7 +75,7 @@ spec:
# macvlan net1 interface on the IoT VLAN — exposing the
# unauthenticated UI to that untrusted segment past the
# Authentik gate. POD_IP is the eth0 IPv4 (status.podIP); the
# Service, the Authentik outpost, and the kubelet httpGet
# Service, Traefik, and the kubelet httpGet
# probes all reach the app on that address. mDNS discovery is
# unaffected: ZeroconfResolver binds its own multicast sockets
# on net1 independently of Kestrel.
@@ -113,11 +113,11 @@ spec:
secretKeyRef:
name: shelly-manager-auth
key: api-key
# SSO: the Authentik proxy outpost (authentik-gate class,
# which the ingress routes through) injects X-authentik-username;
# SSO: Authentik forward auth (authentik-gate class,
# via Traefik ForwardAuth) sets X-authentik-username;
# the app signs that identity in directly, so the gate login is the
# only login. The header is only honored from TrustedProxies — the
# outpost pod's address is dynamic, so this trusts the cluster pod
# Traefik pod's address is dynamic, so this trusts the cluster pod
# CIDR and leans on the namespace CNP to keep other pods off the
# app port. ADMIN_PASSWORD (above) stays as break-glass, e.g. for
# port-forward access when authentik is down. Never open :8080 to
@@ -169,13 +169,13 @@ spec:
memory: 128Mi
limits:
memory: 512Mi
# API-key-only listener for clients other than the Authentik outpost
# API-key-only listener for clients other than Traefik
# (REN-931). nginx on ${POD_IP}:8081 proxies to
# :8080 and drops X-authentik-username, so the ProxyHeader sign-in
# cannot be reached through it. Config and reasoning:
# shelly-manager-api-proxy.conf. Service: shelly-manager-api.
# No probes: it only serves the API-key path, and a probe failure would
# also pull the outpost's endpoint out of the shelly-manager Service.
# also pull the pod out of the shelly-manager Service.
- name: api-proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
env:
@@ -1,6 +1,6 @@
# shelly-manager has no authentication of its own: anything reaching it can
# rewrite Shelly config, push scripts and read stored device AdminPasswords, so
# the Authentik gate (ingress class authentik-gate) is the only thing in front
# the Authentik gate (the authentik-gate Middleware) is the only thing in front
# of it: Authentik SSO login, Shelly Managers only.
#
# SignalR/WebSocket: the websecure entrypoint idleTimeout (180s) is kept alive
@@ -15,10 +15,11 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Shelly Managers
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: shelly-manager.home.renner.uno
http:
@@ -15,7 +15,7 @@ spec:
# API-key-only entry for in-cluster clients (REN-931, REN-935): the api-proxy
# sidecar, which drops the X-authentik-username header. Clients call
# http://shelly-manager-api.home-assistant.svc/api/v1/... with the API key.
# :8080 stays outpost-only.
# :8080 stays Traefik-only.
apiVersion: v1
kind: Service
metadata:
@@ -23,7 +23,7 @@ spec:
# flashing is done in the browser over USB (ESP Web Tools / Web Serial),
# and building/compiling is offline. So the 0.0.0.0:6052 bind stays on the
# Cilium-governed pod network only, where the home-assistant CNP admits
# ONLY the esphome gate outpost (generated CNP esphome-gate; see
# ONLY Traefik (generated CNP esphome-gate; see
# esphome-ingress.yaml) — no unauthenticated IoT-VLAN exposure like the
# macvlan-on-0.0.0.0 hole shelly-manager had to pin Kestrel around.
#
@@ -6,19 +6,20 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: ESPHome Managers
spec:
# ESPHome Device Builder has no auth of its own: anyone reaching it can edit
# device YAML (incl. wifi/api secrets), compile, and flash/OTA firmware, so the
# Authentik gate (ingress class authentik-gate) is the only thing in front of
# Authentik gate (the authentik-gate Middleware) is the only thing in front of
# it: Authentik SSO login, ESPHome Managers only.
#
# WebSocket: the dashboard streams build/OTA logs over a websocket; Traefik's
# websecure entrypoint passes it through. Browser USB flashing uses Web
# Serial, which needs a secure context; the serial link never traverses the
# proxy.
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: esphome.home.renner.uno
http:
@@ -3,7 +3,7 @@
#
# code-server runs with --auth=none as root and mounts the entire /config
# (plaintext secrets.yaml, .storage, the git PAT in the PVC remote URL), so the
# Authentik gate (ingress class authentik-gate) is the only thing in front of
# Authentik gate (the authentik-gate Middleware) is the only thing in front of
# it: Authentik SSO login, Home Assistant Config Editors only.
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -13,13 +13,14 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Home Assistant Config Editors
# code-server's manifest and service worker are fetched without
# credentials. Version-specific: re-check after a code-server bump.
auth.renner.uno/skip-path: ^/(_static/|manifest\.json|.*\.webmanifest|.*serviceWorker\.js).*
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: vscode.home.renner.uno
http:
@@ -6,17 +6,18 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Zigbee2MQTT Managers
spec:
# The Z2M frontend has no auth of its own: anyone reaching it can rename
# devices, toggle permit_join, trigger OTA, and control the whole Zigbee
# network, so the Authentik gate (ingress class authentik-gate) is the only
# network, so the Authentik gate (the authentik-gate Middleware) is the only
# thing in front of it: Authentik SSO login, Zigbee2MQTT Managers only.
#
# WebSocket: the frontend streams live device state / logs over a websocket;
# Traefik's websecure entrypoint passes it through.
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: zigbee2mqtt.home.renner.uno
http:
+4 -3
View File
@@ -1,6 +1,6 @@
---
# llm.renner.uno — the chat WebUI, public and behind an Authentik gate
# (ingress class authentik-gate, kubernetes/apps/authentik/gate-sync.yaml).
# (the authentik-gate Middleware, kubernetes/apps/authentik/gate.yaml).
#
# The gate is the entire access control here: llama-server has no
# authentication of its own and serves a full OpenAI-compatible API on the same
@@ -8,7 +8,7 @@
#
# In-cluster consumers — Home Assistant, Paperless, CI — address
# llama-server.ai.svc.cluster.local:8080 directly and never pass through
# Traefik or the outpost.
# Traefik or Authentik.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -20,12 +20,13 @@ metadata:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
# Manifest and service worker are fetched without credentials; neither
# serves authenticated content.
auth.renner.uno/skip-path: ^/(manifest\.webmanifest|sw\.js)$
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
tls:
- hosts:
- llm.renner.uno
@@ -27,7 +27,7 @@ spec:
- https://home.renner.uno
# prometheus.renner.uno and alerts.renner.uno are Authentik-gated
# (PR #147 / #146). These EXTERNAL probes now measure the full
# gated path — DNS, TLS, Traefik, the gate outpost — following the
# gated path — DNS, TLS, Traefik, Authentik forward auth — following the
# 302 to the Authentik login (200). They are REACHABILITY checks,
# not app health: with the gate up, they stay green even if the app
# behind it is dead (the probe never reaches the app). Real app
@@ -98,7 +98,7 @@ spec:
# rule as the block above (monitoring-vantage-point-rule), but these
# are the first targets here that leave the monitoring namespace.
#
# Both sit entirely behind an Authentik gate outpost (ingress class
# Both sit entirely behind Authentik forward auth (ingress class
# authentik-gate; ../navidrome/ingress.yaml, ../navidrome/lidarr.yaml) — so an
# EXTERNAL probe of either name measures the gate, not the app, and
# stays green while the app behind
@@ -219,7 +219,7 @@ spec:
# (monitoring-vantage-point-rule); the blind spot it closes is the one
# gatus already documents in its Web Service 11 comment
# (../gatus/configmap.yaml#gatus-endpoints) — through the
# gate outpost EVERY path, /api/ included,
# gate EVERY path, /api/ included,
# answers 302 to an unauthenticated caller, so no external probe of
# paperless.renner.uno can tell a live app from a live gate. That
# external signal is still wanted, and REN-2 adds it from UptimeRobot;
@@ -268,7 +268,7 @@ spec:
# anchor: shelly-manager-app-health
# APP-HEALTH probe for shelly-manager (REN-531/REN-533). Same rule as
# the blocks above (monitoring-vantage-point-rule): shelly.renner.uno
# sits behind a gate outpost, so only the in-cluster
# sits behind an Authentik gate, so only the in-cluster
# Service can tell a dead app from a live gate.
#
# /health is unauthenticated and answers a plain 200 — not a 3xx —
@@ -42,7 +42,7 @@ spec:
description: >-
The endpoint is not answering as expected. For the Authentik-gated
hostnames this is a reachability check covering DNS, TLS, Traefik
and the gate outpost — it says nothing about the app behind the
and Authentik forward auth — it says nothing about the app behind the
gate, which the internal /-/healthy probes cover separately.
labels:
severity: warning
@@ -97,7 +97,7 @@ tests:
instance: https://other.renner.uno
exp_annotations:
summary: "Blackbox probe of https://other.renner.uno has been failing for 10 minutes"
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and the gate outpost — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and Authentik forward auth — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
# Healthy probe stays silent.
- interval: 1m
@@ -1,7 +1,7 @@
---
# alerts.renner.uno — Alertmanager. Its v2 API is writable (POST
# /api/v2/silences can suppress all alerting) and it has no auth of its own, so
# the Authentik gate (ingress class authentik-gate) is the only thing in front
# the Authentik gate (the authentik-gate Middleware) is the only thing in front
# of it: Monitoring Access only. Replaces the chart-generated Ingress
# (alertmanager.ingress.enabled=false in helmrelease-prometheus.yaml).
apiVersion: networking.k8s.io/v1
@@ -12,10 +12,11 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Monitoring Access
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: alerts.renner.uno
http:
@@ -1,7 +1,7 @@
---
# prometheus.renner.uno — Prometheus. It has no auth of its own and its metrics
# DB exposes hostnames, IPs and service inventory, so the Authentik gate
# (ingress class authentik-gate) is the only thing in front of it: Monitoring
# (the authentik-gate Middleware) is the only thing in front of it: Monitoring
# Access only. Replaces the chart-generated Ingress
# (prometheus.ingress.enabled=false in helmrelease-prometheus.yaml).
apiVersion: networking.k8s.io/v1
@@ -12,10 +12,11 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Monitoring Access
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: prometheus.renner.uno
http:
+5 -4
View File
@@ -8,14 +8,15 @@ data:
ND_LOGLEVEL: "info"
ND_SCANNER_SCHEDULE: "0 * * * *"
ND_ENABLEINSIGHTSCOLLECTOR: "false"
# Identity from the gate outpost (ingress class authentik-gate). Traefik strips this header on
# the ungated /rest and /share routes.
# Identity from Authentik forward auth (ingress class authentik-gate),
# set by Traefik. Traefik strips this header on the ungated /rest and
# /share routes.
#
# ND_EXTAUTH_TRUSTEDSOURCES BELOW IS NOT A NARROWING. fd10:244::/48 plus
# 10.244.0.0/16 is the entire cluster pod CIDR, so navidrome's own
# reverse-proxy trust check accepts X-Authentik-Username from ANY pod.
# The CiliumNetworkPolicy is the only real boundary, and it is wider than
# "the outpost and Traefik" — default-deny-ingress in
# Traefik alone — default-deny-ingress in
# kubernetes/infrastructure/network-policies/navidrome-ns.yaml uses
# `endpointSelector: {}` and admits, on every port:
# - the navidrome namespace, every port
@@ -29,7 +30,7 @@ data:
# cited by what they admit, not by line number, so this list cannot go
# stale the next time that file is edited.
# The value stays at the pod CIDR ON PURPOSE - see REN-28. The address
# navidrome sees is the outpost's POD IP, which is not stable across
# navidrome sees is the Traefik POD IP, which is not stable across
# reschedules, so a narrower literal here fails open-ended and silently
# the next time that pod moves. Tightening the CNP is the control that
# would actually do work.
+3 -2
View File
@@ -1,4 +1,4 @@
# Web UI gated by authentik-gate-sync (ingress class authentik-gate).
# Web UI gated by authentik-gate-sync (the authentik-gate Middleware).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -8,9 +8,10 @@ metadata:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: navidrome.renner.uno
http:
+4 -3
View File
@@ -101,7 +101,7 @@ spec:
value: "1000"
- name: TZ
value: Europe/Berlin
# Identity is enforced by the gate outpost (ingress class authentik-gate).
# Identity is enforced by Authentik forward auth (the authentik-gate Middleware).
- name: LIDARR__AUTH__METHOD
value: External
ports:
@@ -186,7 +186,7 @@ spec:
port: 80
targetPort: http
---
# Gated by authentik-gate-sync (ingress class authentik-gate).
# Gated by authentik-gate-sync (the authentik-gate Middleware).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -196,10 +196,11 @@ metadata:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Music Librarians
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: lidarr.renner.uno
http:
@@ -2,11 +2,9 @@
# Ingress so the alias-host redirect could be expressed as a Traefik
# RedirectRegex Middleware instead of an nginx configuration-snippet.
#
# The Middleware short-circuits before the backend, so `paperless-gate` (the
# ExternalName authentik-gate-sync generates for the paperless Ingress) is a
# structural backend only — it is never reached. Even if the redirect
# regex misses, the outpost rejects non-canonical Host headers with 404,
# so there is no failure mode that leaks paperless access through this Ingress.
# The redirect short-circuits before the backend. Should its regex miss, the
# Authentik gate behind it admits Paperless Users only, as on
# paperless.renner.uno, so nothing leaks paperless access through this Ingress.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
@@ -30,7 +28,8 @@ metadata:
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: paperless-docs-to-paperless-redirect@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: paperless-docs-to-paperless-redirect@kubernetescrd,authentik-gate@kubernetescrd
auth.renner.uno/group: Paperless Users
spec:
ingressClassName: traefik
rules:
@@ -41,9 +40,9 @@ spec:
pathType: Prefix
backend:
service:
name: paperless-gate
name: paperless
port:
number: 9000
number: 80
tls:
- secretName: docs-renner-uno-tls
hosts:
@@ -1,13 +1,14 @@
---
# Ingress from the gate outpost is the CNP paperless-gate that
# authentik-gate-sync generates next to the Ingress. Header-based remote-user
# auth means paperless trusts X-Authentik-Username unconditionally, so any pod
# that reaches its HTTP listener can claim any username.
# Ingress from traefik is the CNP paperless-gate that authentik-gate-sync
# generates next to the Ingress; traefik sets X-Authentik-Username from
# Authentik forward auth. Header-based remote-user auth means paperless trusts
# that header unconditionally, so any pod that reaches its HTTP listener can
# claim any username.
#
# What is enforced is wider than that CNP: default-deny-ingress in
# kubernetes/infrastructure/network-policies/paperless-ns.yaml uses
# `endpointSelector: {}`, and Cilium unions the allow rules of every CNP
# selecting an endpoint. Besides the outpost, the pod admits:
# selecting an endpoint. Besides traefik, the pod admits:
# - the paperless namespace, every port
# ../../infrastructure/network-policies/paperless-ns.yaml#paperless-admit
# - host / remote-node, every port
+8 -7
View File
@@ -4,9 +4,9 @@ metadata:
name: paperless-config
namespace: paperless
data:
# Identity comes from the Authentik gate outpost via the
# X-Authentik-Username request header (set by the outpost in proxy
# mode). Paperless's HTTP_REMOTE_USER auth backend auto-creates the
# Identity comes from Authentik forward auth via the
# X-Authentik-Username request header (set by Traefik from Authentik's
# answer). Paperless's HTTP_REMOTE_USER auth backend auto-creates the
# user on first request and treats the gate as the source of truth.
# Both flags are required: ENABLE_HTTP_REMOTE_USER covers the Django
# views (web UI), ENABLE_HTTP_REMOTE_USER_API covers DRF endpoints
@@ -182,7 +182,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.3 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.4 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -344,8 +344,8 @@ spec:
- port: 80
targetPort: http
---
# Canonical paperless.renner.uno, gated by authentik-gate-sync (ingress class
# authentik-gate). The docs.renner.uno alias lives in
# Canonical paperless.renner.uno, gated by authentik-gate-sync (the
# authentik-gate Middleware). The docs.renner.uno alias lives in
# docs-traefik-ingress.yaml. The static-asset tree passes anonymously because
# the manifest is fetched without credentials; /api/documents/* stays gated.
apiVersion: networking.k8s.io/v1
@@ -359,12 +359,13 @@ metadata:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Paperless Users
auth.renner.uno/authorization-flow: paperless-authorization
auth.renner.uno/skip-path: ^/static/.*
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: paperless.renner.uno
http:
@@ -252,7 +252,7 @@ metadata:
namespace: uptime-monitor
spec:
# `https://www.renner.uno` and not `/wp-admin` or `/wp-login.php`: those two
# paths are served by a SECOND Ingress that sits behind the Authentik outpost
# paths are served by a SECOND Ingress that sits behind Authentik forward auth
# (../wordpress/ingress.yaml#gated-paths). `/` is the ungated one.
url: https://www.renner.uno/
uptimeRobotConfig:
+5 -12
View File
@@ -37,10 +37,9 @@ spec:
hosts:
- www.renner.uno
---
# wp-admin, wp-login.php, xmlrpc.php and the outpost's own OAuth leg
# (/outpost.goauthentik.io) are gated by authentik-gate-sync (ingress class
# authentik-gate): WordPress Authors only. The public Ingress above owns the
# Certificate for wordpress-tls.
# wp-admin, wp-login.php and xmlrpc.php are behind the Authentik gate
# (authentik-gate Middleware): WordPress Authors only. The public Ingress
# above owns the Certificate for wordpress-tls.
#
# /wp-admin covers admin-ajax.php, which is safe because no plugin on the site
# emits an ajaxurl. xmlrpc.php goes through the gate because an Ingress has no
@@ -54,9 +53,10 @@ metadata:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
auth.renner.uno/group: WordPress Authors
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
tls:
- secretName: wordpress-tls
hosts:
@@ -65,13 +65,6 @@ spec:
- host: www.renner.uno
http:
paths:
- path: /outpost.goauthentik.io
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
# anchor: gated-paths
- path: /wp-admin
pathType: Prefix
@@ -1,8 +1,8 @@
---
# zone-configurator.home.renner.uno — the Everything Presence mmWave zone
# configurator. It holds an HA long-lived token (HA_LONG_LIVED_TOKEN) and has
# no authentication of its own, so the Authentik gate (ingress class
# authentik-gate) is the only thing in front of it: Authentik SSO login, Zone
# no authentication of its own, so the Authentik gate (the
# authentik-gate Middleware) is the only thing in front of it: Authentik SSO login, Zone
# Configurators only.
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -12,10 +12,11 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Zone Configurators
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: zone-configurator.home.renner.uno
http:
@@ -9,12 +9,10 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
# Traefik is deliberately NOT admitted. llm.renner.uno is public and
# llama-server has no authentication of its own, so its Authentik gate
# outpost is the only thing standing in front of it; authentik-gate-sync
# admits that outpost with its own CNP (llama-server-gate). With no ingress
# rule for traefik, an Ingress that pointed straight at the Service — by
# accident or otherwise — simply would not connect.
# Traefik is not admitted namespace-wide. llm.renner.uno is public and
# llama-server has no authentication of its own, so Authentik forward auth
# is the only thing standing in front of it; authentik-gate-sync admits
# traefik to llama-server alone with its own CNP (llama-server-gate).
# Paperless calls both models for AI suggestions and RAG embeddings. The
# matching egress grant on its side is scoped to these two pods on :8080.
- fromEndpoints:
@@ -10,8 +10,8 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
# traefik reaches the authentik-server (auth.renner.uno) and the
# gate outposts living in this namespace.
# traefik reaches authentik-server: auth.renner.uno and the gates'
# forward-auth requests to the embedded outpost.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
@@ -25,7 +25,7 @@ spec:
- ports:
- port: "8124"
protocol: TCP
# Gate outposts (vscode, shelly-manager, esphome, zigbee2mqtt,
# Gated backends (vscode, shelly-manager, esphome, zigbee2mqtt,
# zone-configurator) are admitted by the per-gate CNPs authentik-gate-sync
# generates next to each Ingress, scoped to the backend pods.
# Prometheus scrape: kube-prometheus-stack hits HA's /api/prometheus.
@@ -24,7 +24,7 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": flux-system
# The alertmanager and prometheus gate outposts are admitted by the CNPs
# Traefik to the alertmanager and prometheus gates is admitted by the CNPs
# authentik-gate-sync generates next to the Ingresses.
# Kubelet probes + control-plane (PrometheusRule/ServiceMonitor admission
# webhooks via kube-prometheus-stack-operator are called by kube-apiserver).
@@ -14,7 +14,7 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
# The gate outposts are admitted by the CNPs authentik-gate-sync generates
# Traefik to the gated backends is admitted by the CNPs authentik-gate-sync generates
# next to the Ingresses (navidrome-gate, lidarr-gate).
# blackbox-exporter only, on the two app listeners — NOT the whole
# monitoring namespace, which this used to admit on every port (REN-28).
@@ -21,14 +21,11 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless
# traefik is intentionally NOT in this list. paperless's
# URL-facing entry is the gate outpost in the authentik namespace;
# traefik routes paperless.renner.uno to that outpost via the
# ExternalName service authentik-gate-sync generates, so the TCP
# connection terminates in the authentik namespace and never opens into
# the paperless namespace directly. Allowing traefik here would broaden
# the trust boundary off the gate outpost; the outpost is admitted by the
# generated CNP paperless-gate.
# traefik is intentionally NOT in this list: on endpointSelector:{} it
# would reach all nine workloads. authentik-gate-sync admits traefik to
# the paperless pod alone with the generated CNP paperless-gate; on that
# route Traefik replaces every X-Authentik-* header with Authentik's
# forward-auth answer.
#
# `database` and `monitoring` are intentionally NOT in this list either,
# and for the same reason: on endpointSelector:{} they reached all nine
@@ -15,7 +15,7 @@ spec:
#
# Be clear about what this does and does not buy: the public/admin split
# is enforced by the two Ingress objects' path rules (`/` straight to the
# Service, /wp-admin and /wp-login.php to the gate outpost) and by
# Service, /wp-admin and /wp-login.php through the gate) and by
# Authentik — NOT at the network layer. paperless can point a
# workload-scoped CNP at its gate because nothing else may reach it;
# here traefik must reach the pod directly for every public page, so the
@@ -26,7 +26,7 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
# The gate outpost for wp-admin is admitted by the CNP authentik-gate-sync
# Traefik for wp-admin is admitted by the CNP authentik-gate-sync
# generates next to the wordpress-admin Ingress (wordpress-admin-gate).
# The mariadb-operator reconciles the MariaDB CR from the database
# namespace — same cross-namespace requirement CNPG consumers have.
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-22 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-22 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-22 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -65,15 +65,9 @@ spec:
providers:
kubernetesIngress:
enabled: true
# The gate Services (<ingress>-gate, generated by authentik-gate-sync)
# are ExternalNames pointing at the Authentik outposts in the
# authentik namespace. Traefik blocks ExternalName backends by
# default; this enables the house pattern.
allowExternalNameServices: true
kubernetesCRD:
enabled: true
# Future Authentik ForwardAuth Middleware will live in `authentik`
# namespace and be referenced from Ingresses in app namespaces.
allowCrossNamespace: true
allowExternalNameServices: true
ports: