Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 a3eb69ee20 docs: drop references to the deleted *.renner.uno wildcard
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The public Cloudflare wildcard CNAME *.renner.uno -> proxy.renner.uno and
its carve-out TXTs were removed on 2026-10-22. Public hosts now carry an
explicit CNAME to proxy.renner.uno created by external-dns from
dns.renner.uno/public; UDM records are opt-in via dns.renner.uno/internal.

Comment/doc text only; no config value changes:
- KumaMonitor, Ingress, HelmRelease and tofu comments that said hosts
  resolve via the wildcard or that external-dns-unifi "skips" public hosts.
- PrometheusRule descriptions (k8s-gateway, dns-resolution) and the
  matching promtool test strings.
- k8s-gateway cache comment: the cache tuning stays (dnsmasq refusal
  bursts, 5s lease TTLs); the wildcard-trap rationale is gone.
- hermes skill references, claude-workstation runbook, crowdsec bouncer
  defaults.

Not changed: kubernetes/apps/home-assistant/* (README rule 1 window) and
the wildcard sentence inside the CoreDNS Corefile data
(infrastructure/coredns-config), where an edit changes the ConfigMap.

Verified live before the edit: all 30 Kuma DNS monitors and all blackbox
DNS/HTTP probes green after the wildcard removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:08:07 +02:00
68 changed files with 467 additions and 442 deletions
+2 -3
View File
@@ -95,9 +95,8 @@ the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6)
> inventory targets the tunnel DHCP IP.
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
> the off-site DNS** (`@10.3.40.1`). The home resolver catch-alls `*.renner.uno`
> to the public proxy (`162.55.50.97`, the Netbird VPS) — so from home the FQDN
> lands on `root@netbird`, **not** the VM. Never use it as `ansible_host`.
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
over the mesh too, add a Netbird dashboard **network route** advertising the
@@ -3,7 +3,7 @@
#
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
# where public traffic actually enters: these hosts terminate TLS for every
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
# public renner.uno name. A drop here never reaches the mesh, the relay or the
# cluster.
#
# Detection stays in the cluster. The reverse-proxy produces no parseable
+193 -101
View File
@@ -1,26 +1,34 @@
#!/usr/bin/env python3
"""authentik-gate-sync: the Authentik side of the gate Middleware.
"""authentik-gate-sync: an ingress class that puts an Authentik proxy gate in front of an app.
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:
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:
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.
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.
Annotations (all optional):
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):
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: 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.
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.
Ownership: Kubernetes objects carry app.kubernetes.io/managed-by; Authentik
applications carry meta_publisher. Anything without the marker is never touched.
@@ -40,8 +48,10 @@ 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")
GATE_MIDDLEWARE = os.environ.get("GATE_MIDDLEWARE", "authentik-gate@kubernetescrd")
INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
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")
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")
@@ -53,9 +63,14 @@ ANN = "auth.renner.uno/"
MANAGER = "authentik-gate-sync"
MANAGED_BY = {"app.kubernetes.io/managed-by": MANAGER}
GATE_LABEL = "auth.renner.uno/gate"
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"
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")
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
@@ -99,7 +114,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 (63)."""
and short enough for label values and ak-outpost-<name> (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
@@ -111,24 +126,6 @@ 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"]
@@ -177,15 +174,63 @@ 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": TRAEFIK}],
"ingress": [{"fromEndpoints": [{"matchLabels": outpost_selector(gate)}],
"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
@@ -209,19 +254,34 @@ def resolve_backend(gate, service):
return sp["port"], selector, target
def embedded_config(config):
"""The embedded outpost's config with the hosts the gates depend on: the API
over the in-cluster Service, the browser sent to the public URL."""
return {**(config or {}), "authentik_host": AK_URL, "authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL}
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 provider_body(gate, flows):
def provider_body(gate, svc_port, flows):
return {
"name": gate["name"],
"mode": "forward_single",
"mode": "proxy",
"external_host": f"{gate['scheme']}://{gate['host']}",
"internal_host": "",
"internal_host": f"http://{gate['service']}.{gate['namespace']}.svc.cluster.local:{svc_port}",
"internal_host_ssl_validation": False,
"cookie_domain": COOKIE_DOMAIN,
"access_token_validity": "hours=24",
"refresh_token_validity": "days=30",
@@ -233,6 +293,18 @@ def provider_body(gate, 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}
@@ -270,9 +342,23 @@ class K8s:
if e.code != 404:
raise
# 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.
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"])
COLLECTIONS = {
"Ingress": "/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses",
"Service": "/api/v1/namespaces/{ns}/services",
@@ -339,24 +425,7 @@ def get_application(ak, slug):
return ak.get(f"core/applications/{slug}/")
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):
def ensure_gate(ak, gate, svc_port, flows, connection):
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")
@@ -365,7 +434,7 @@ def ensure_gate(ak, gate, flows):
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, flows)
want = provider_body(gate, svc_port, flows)
provider = ak.one("providers/proxy/", name__iexact=name)
if provider is None:
provider = ak.req("POST", "providers/proxy/", want)
@@ -392,22 +461,17 @@ def ensure_gate(ak, gate, flows):
ensure_access(ak, gate, app["pbm_uuid"])
# 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)
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)))
def ensure_access(ak, gate, target):
@@ -447,12 +511,15 @@ def ensure_access(ak, gate, target):
def remove_gate(ak, app):
"""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."""
"""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."""
name, provider = app["slug"], app.get("provider")
remove_dedicated_outposts(ak, name, provider)
for o in ak.list("outposts/instances/", name__iexact=name):
if o.get("providers") == [provider]:
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"providers/proxy/{provider}/")
@@ -476,15 +543,32 @@ 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 is_gated(i)]
shared = shared_hosts(sources)
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
if i.get("spec", {}).get("ingressClassName") == INGRESS_CLASS]
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
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"]
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
gates = set() # gate names whose Authentik side should exist
@@ -494,8 +578,6 @@ 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")
@@ -506,18 +588,28 @@ def reconcile():
if e.code == 404:
raise GateError(f"backend Service {gate['service']} does not exist") from None
raise
_, pods, target_port = resolve_backend(gate, service)
svc_port, pods, target_port = resolve_backend(gate, service)
gates.add(gate["name"])
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"]))
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
except GateError as e:
# The Ingress cannot be gated as written: its provider goes with the
# sweep below, after which the outpost rejects the host.
# The Ingress cannot be gated as written: its route goes now, the
# rest with the sweep below.
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,12 +11,11 @@ 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":
"authentik-gate@kubernetescrd, ingress-foo@kubernetescrd",
"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd",
},
},
"spec": {
"ingressClassName": "traefik",
"ingressClassName": "authentik-gate",
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
{"path": "/", "pathType": "Prefix",
@@ -42,7 +41,7 @@ class Names(unittest.TestCase):
def test_bounded(self):
n = c.gate_name(ing("n" * 63, "i" * 253))
self.assertLessEqual(len(n), 63)
self.assertLessEqual(len(c.outpost_service({"name": n})), 63)
self.assertNotIn("--", n)
def test_group_expression_is_a_literal(self):
@@ -91,22 +90,6 @@ 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):
@@ -143,34 +126,65 @@ class Objects(unittest.TestCase):
def setUp(self):
self.g = c.gate_spec(LLAMA)
def test_backend_admits_traefik_only(self):
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):
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"],
[{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "ingress",
"app.kubernetes.io/name": "traefik"}}])
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]["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, flows)
self.assertEqual(p["mode"], "forward_single")
p = c.provider_body(self.g, 8080, flows)
self.assertEqual(p["external_host"], "https://llm.renner.uno")
self.assertEqual(p["internal_host"], "")
self.assertEqual(p["internal_host"], "http://llama-server.ai.svc.cluster.local:8080")
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": "https://auth.renner.uno/"})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_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."""
@@ -253,69 +267,20 @@ 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, FLOWS)
c.ensure_gate(self.ak, g, 8080, FLOWS, "conn")
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)
self.assertEqual(self.embedded_providers(), [app["provider"]])
self.assertEqual(self.ak.list("outposts/instances/", name__iexact=self.g["name"]), [])
outpost = self.ak.one("outposts/instances/", name__iexact=self.g["name"])
self.assertEqual(outpost["providers"], [app["provider"]])
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_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)
@@ -364,15 +329,15 @@ class Reconcile(unittest.TestCase):
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
def test_remove_gate_and_ownership_scan(self):
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.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": 1})
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([o["pk"] for o in self.ak.list("outposts/instances/")], [self.embedded["pk"]])
self.assertEqual(self.ak.list("outposts/instances/"), [])
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
self.gate()
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: audiobookshelf
namespace: audiobookshelf
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: audiobooks.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -374,10 +374,16 @@ data:
name: lidarr-gate
state: absent
# 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.
- 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: []
# Shared LDAPS cert for the LDAP Outpost. Authentik's data model
# owns the cert per-LDAPProvider (no outpost-level cert field), so
+22 -11
View File
@@ -1,9 +1,15 @@
# 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.
#
# 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
---
# 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.
@@ -45,9 +51,8 @@ metadata:
labels:
app.kubernetes.io/name: authentik-gate-sync
---
# 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.
# Cluster-wide: gated Ingresses live in any namespace, and so do the route
# Ingress, ExternalName Service and backend CNP generated next to them.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
@@ -55,10 +60,14 @@ metadata:
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "delete"]
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"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "delete"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
@@ -116,6 +125,8 @@ 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
@@ -1,38 +0,0 @@
# 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: {}
+2 -2
View File
@@ -161,8 +161,8 @@ spec:
enabled: true
ingressClassName: traefik
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: auth.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -6,7 +6,6 @@ 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 forward auth. It
# therefore reachability only: DNS, TLS, Traefik and the outpost. It
# stays green if paperless itself is dead behind a live gate.
#
# Closing that blind spot needs an in-cluster probe of the paperless
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: gatus
namespace: apps
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: status.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: gitea-mcp.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: git.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -90,7 +90,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: gitea.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -5,8 +5,12 @@
is a TLS-passthrough service on :443 with a PROXY v2 header, going to Traefik `websecure`.
There are no UDM port forwards and no other public L4 path. LB VIPs (`bgp-pool`,
`fd99::/64`, `10.2.99.0/24`) are LAN/mesh-only.
- DNS labels on an Ingress: `dns.renner.uno/internal` writes the UDM (LAN) record
(opt-in), `dns.renner.uno/public` writes the Cloudflare CNAME to `proxy.renner.uno`, and
`netbird.renner.uno/public` makes `netbird-service-sync` publish the proxy service. A
public host needs both public labels.
- `netbird-service-sync` publishes **only hosts on Ingresses** labelled
`dns.renner.uno/public` (or `netbird.renner.uno/public`), and it reads no other kind.
`netbird.renner.uno/public`, and it reads no other kind.
To publish a TLS host served by an `IngressRouteTCP` (TLS terminated by Traefik), put the
host on its **own** labelled Ingress annotated
`traefik.ingress.kubernetes.io/router.entrypoints: web` with **no** `tls:` section, and
@@ -16,15 +16,15 @@ monitor, and its history starts over.
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
## Which hosts are public
The public set is exactly the hosts on Ingresses labelled `dns.renner.uno/public` or
`netbird.renner.uno/public`. To confirm it, do a TLS handshake against the proxy-1 and
A public host needs both labels on its Ingress: `netbird.renner.uno/public` for the proxy
service and `dns.renner.uno/public` for the Cloudflare CNAME. To confirm it, do a TLS handshake against the proxy-1 and
proxy-2 addresses (resolved via DoH) with each candidate hostname as the SNI. Only public hosts
return a CA-verified certificate. The proxies do not answer for their own names, so a
handshake with `proxy-N.renner.uno` as the SNI fails.
## Checks per host (owner's spec: DNS + HTTP reachability + response validation)
- **DNS.** A cluster host has no record of its own and resolves through the `*.renner.uno`
wildcard. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
- **DNS.** A cluster host's public record is a CNAME to `proxy.renner.uno` created by
external-dns from `dns.renner.uno/public`. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
proxy.renner.uno}]`. An external host checks its own AAAA record, or A when it has no
AAAA. Use `resolveServer: "2606:4700:4700::1111,1.1.1.1"` so IPv6 comes first; Kuma
fails over through the list in order.
@@ -71,7 +71,7 @@ reached Kuma.
parent exists and is a group.
- Emulate each http/keyword/json-query check the way Kuma does it: axios, Kuma's
`evaluateJsonQuery` logic via `jsonata`, and a custom `lookup` that resolves through DoH,
because this pod's DNS is split-horizon. Run each wildcard host pinned to proxy-1 and to
because this pod's DNS is split-horizon. Run each public host pinned to proxy-1 and to
proxy-2.
- DNS monitors can't be checked from the cluster, because outbound :53 to external
resolvers is blocked. After merge, read `monitor_status` from Prometheus instead
@@ -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 Traefik may
# reach :8080 directly. Other clients reach this :8081 instead,
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only the Authentik
# outpost 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, Traefik, and the kubelet httpGet
# Service, the Authentik outpost, 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.
@@ -90,8 +90,7 @@ spec:
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
# Echo) are AUTO-DISCOVERED on the IoT VLAN via mDNS
# (_esphomelib._tcp) over the same net1 macvlan route as the Shellys —
# no static hostname/DNS (which the *.renner.uno wildcard would poison
# over IPv6). The shared fleet Noise key (= the dashboard's
# no static hostname/DNS. The shared fleet Noise key (= the dashboard's
# shelly_ble_proxy_api_key) lets shelly-manager complete the encrypted
# native-API handshake to each discovered proxy: DeviceInfo on
# /ble-radios today, the BLE scan/GATT provisioning path next.
@@ -114,11 +113,11 @@ spec:
secretKeyRef:
name: shelly-manager-auth
key: api-key
# SSO: Authentik forward auth (authentik-gate class,
# via Traefik ForwardAuth) sets X-authentik-username;
# SSO: the Authentik proxy outpost (authentik-gate class,
# which the ingress routes through) injects 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
# Traefik pod's address is dynamic, so this trusts the cluster pod
# outpost 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
@@ -170,13 +169,13 @@ spec:
memory: 128Mi
limits:
memory: 512Mi
# API-key-only listener for clients other than Traefik
# API-key-only listener for clients other than the Authentik outpost
# (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 pod out of the shelly-manager Service.
# also pull the outpost's endpoint 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 (the authentik-gate Middleware) is the only thing in front
# the Authentik gate (ingress class authentik-gate) 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,11 +15,10 @@ 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: traefik
ingressClassName: authentik-gate
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 Traefik-only.
# :8080 stays outpost-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 Traefik (generated CNP esphome-gate; see
# ONLY the esphome gate outpost (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,20 +6,19 @@ 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 (the authentik-gate Middleware) is the only thing in front of
# Authentik gate (ingress class authentik-gate) 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: traefik
ingressClassName: authentik-gate
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 (the authentik-gate Middleware) is the only thing in front of
# Authentik gate (ingress class authentik-gate) is the only thing in front of
# it: Authentik SSO login, Home Assistant Config Editors only.
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -13,14 +13,13 @@ 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: traefik
ingressClassName: authentik-gate
rules:
- host: vscode.home.renner.uno
http:
@@ -6,18 +6,17 @@ 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 (the authentik-gate Middleware) is the only
# network, so the Authentik gate (ingress class authentik-gate) 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: traefik
ingressClassName: authentik-gate
rules:
- host: zigbee2mqtt.home.renner.uno
http:
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: immich
namespace: immich
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: immich.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+5 -7
View File
@@ -1,6 +1,6 @@
---
# llm.renner.uno — the chat WebUI, public and behind an Authentik gate
# (the authentik-gate Middleware, kubernetes/apps/authentik/gate.yaml).
# (ingress class authentik-gate, kubernetes/apps/authentik/gate-sync.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,26 +8,24 @@
#
# In-cluster consumers — Home Assistant, Paperless, CI — address
# llama-server.ai.svc.cluster.local:8080 directly and never pass through
# Traefik or Authentik.
# Traefik or the outpost.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llama-server
namespace: ai
labels:
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
# external-dns instance skips anything carrying this, and LAN clients reach
# the same hostname through the hairpin NAT.
# Public: Cloudflare CNAME → NetBird proxy → Traefik. No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
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: traefik
ingressClassName: authentik-gate
tls:
- hosts:
- llm.renner.uno
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: llm.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+10 -9
View File
@@ -1,8 +1,9 @@
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
# netbird-service-sync publishes its host through the NetBird proxy
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
# (explicit Cloudflare CNAME -> proxy.renner.uno -> TLS passthrough to Traefik)
# and there is no dns.renner.uno/internal label, so external-dns-unifi writes
# no UDM record; LAN clients follow the public answer and the UDM hairpins
# back in. Same path as audiobooks/immich. Federation stays off.
#
# Synapse: only the client-facing paths. /_synapse/admin stays off the
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
@@ -13,8 +14,8 @@ metadata:
name: synapse
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -123,8 +124,8 @@ metadata:
name: element-web
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -154,8 +155,8 @@ metadata:
name: mas
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+5 -5
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -136,7 +136,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: account.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -202,7 +202,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: element.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -289,7 +289,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: rtc.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -354,7 +354,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: turn.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -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, Authentik forward auth — following the
# gated path — DNS, TLS, Traefik, the gate outpost — 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 Authentik forward auth (ingress class
# Both sit entirely behind an Authentik gate outpost (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 EVERY path, /api/ included,
# gate outpost 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 an Authentik gate, so only the in-cluster
# sits behind a gate outpost, 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 Authentik forward auth — it says nothing about the app behind the
and the gate outpost — it says nothing about the app behind the
gate, which the internal /-/healthy probes cover separately.
labels:
severity: warning
@@ -25,8 +25,8 @@ spec:
summary: "{{ $labels.dns_name }} no longer resolves to its LAN address"
description: >-
The UDM stopped answering for a name external-dns publishes from
a LoadBalancer Service, or answered with the public
*.renner.uno wildcard instead of the record. Clients that use
a LoadBalancer Service, or answered without the
record (no answer, or a public CNAME). Clients that use
the name rather than the address lose the service while
everything in the cluster stays healthy — nothing else in the
stack reports this. Check the external-dns alerts first, then
@@ -31,8 +31,9 @@ spec:
{{ $value | humanizePercentage }} of probes in the last 10m got
the Ingress address for {{ $labels.dns_name }} ({{ $labels.record }})
from k8s-gateway. Pods then resolve Ingress names through the UDM
and the public wildcard (CNAME proxy.renner.uno) and leave the
cluster. Check the rendered Corefile in the k8s-gateway ConfigMap
and the public zone (CNAME proxy.renner.uno for public hosts)
and leave the cluster. Check the rendered Corefile in the
k8s-gateway ConfigMap
(kube-system) for the renner.uno zone, the k8s-gateway logs, and
whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml
still matches the dns_k8s_gateway_auth_* modules in
@@ -30,7 +30,7 @@ tests:
record: A
exp_annotations:
summary: "k8s-gateway does not answer auth.renner.uno A with the Traefik VIP"
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
- eval_time: 17m
alertname: K8sGatewayProbesAbsent
exp_alerts: []
@@ -53,7 +53,7 @@ tests:
record: AAAA
exp_annotations:
summary: "k8s-gateway does not answer auth.renner.uno AAAA with the Traefik VIP"
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
# Two consecutive failures in an otherwise healthy hour stay silent
# (average exactly 0.8).
@@ -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 Authentik forward auth — 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 the gate outpost — 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 (the authentik-gate Middleware) is the only thing in front
# the Authentik gate (ingress class authentik-gate) 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,11 +12,10 @@ 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: traefik
ingressClassName: authentik-gate
rules:
- host: alerts.renner.uno
http:
@@ -78,11 +78,10 @@ spec:
# passed per scrape. The Probes in monitoring-rules/probes-dns.yaml
# point these at the UDM.
#
# Both answer checks are load-bearing. valid_rcodes alone passes a
# deleted record two labels deep (NOERROR, no answer), and the public
# *.renner.uno wildcard answers one label deep with a CNAME to
# proxy.renner.uno — NOERROR with the WAN proxy behind it. Requiring
# an A and rejecting a CNAME separates a real record from both.
# Both answer checks are load-bearing: valid_rcodes alone passes a
# deleted record (NOERROR, no answer, any depth). Requiring an A
# separates a real record from that; a CNAME means the UDM answered with a
# public-zone target instead of the LAN address.
dns_lan_ha:
prober: dns
timeout: 5s
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# target of the *.renner.uno wildcard
# target of every public CNAME (NetBird proxy load balancer)
hostname: proxy.renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -228,7 +228,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# Cloudflare-proxied apex, not the wildcard
# Cloudflare-proxied apex
hostname: renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -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
# (the authentik-gate Middleware) is the only thing in front of it: Monitoring
# (ingress class authentik-gate) 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,11 +12,10 @@ 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: traefik
ingressClassName: authentik-gate
rules:
- host: prometheus.renner.uno
http:
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: music-assistant
namespace: music-assistant
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: music.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+4 -5
View File
@@ -8,15 +8,14 @@ data:
ND_LOGLEVEL: "info"
ND_SCANNER_SCHEDULE: "0 * * * *"
ND_ENABLEINSIGHTSCOLLECTOR: "false"
# Identity from Authentik forward auth (ingress class authentik-gate),
# set by Traefik. Traefik strips this header on the ungated /rest and
# /share routes.
# Identity from the gate outpost (ingress class authentik-gate). 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
# Traefik alone — default-deny-ingress in
# "the outpost and Traefik" — default-deny-ingress in
# kubernetes/infrastructure/network-policies/navidrome-ns.yaml uses
# `endpointSelector: {}` and admits, on every port:
# - the navidrome namespace, every port
@@ -30,7 +29,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 Traefik POD IP, which is not stable across
# navidrome sees is the outpost's 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.
+2 -3
View File
@@ -1,4 +1,4 @@
# Web UI gated by authentik-gate-sync (the authentik-gate Middleware).
# Web UI gated by authentik-gate-sync (ingress class authentik-gate).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -8,10 +8,9 @@ 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: traefik
ingressClassName: authentik-gate
rules:
- host: navidrome.renner.uno
http:
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: navidrome.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -101,7 +101,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: lidarr.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+3 -4
View File
@@ -101,7 +101,7 @@ spec:
value: "1000"
- name: TZ
value: Europe/Berlin
# Identity is enforced by Authentik forward auth (the authentik-gate Middleware).
# Identity is enforced by the gate outpost (ingress class authentik-gate).
- name: LIDARR__AUTH__METHOD
value: External
ports:
@@ -186,7 +186,7 @@ spec:
port: 80
targetPort: http
---
# Gated by authentik-gate-sync (the authentik-gate Middleware).
# Gated by authentik-gate-sync (ingress class authentik-gate).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -196,11 +196,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
auth.renner.uno/group: Music Librarians
spec:
ingressClassName: traefik
ingressClassName: authentik-gate
rules:
- host: lidarr.renner.uno
http:
@@ -2,9 +2,11 @@
# Ingress so the alias-host redirect could be expressed as a Traefik
# RedirectRegex Middleware instead of an nginx configuration-snippet.
#
# 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.
# 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.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
@@ -22,14 +24,13 @@ metadata:
name: docs
namespace: paperless
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
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,authentik-gate@kubernetescrd
auth.renner.uno/group: Paperless Users
traefik.ingress.kubernetes.io/router.middlewares: paperless-docs-to-paperless-redirect@kubernetescrd
spec:
ingressClassName: traefik
rules:
@@ -40,9 +41,9 @@ spec:
pathType: Prefix
backend:
service:
name: paperless
name: paperless-gate
port:
number: 80
number: 9000
tls:
- secretName: docs-renner-uno-tls
hosts:
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: docs.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -88,7 +88,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: paperless.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -1,14 +1,13 @@
---
# 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.
# 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.
#
# 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 traefik, the pod admits:
# selecting an endpoint. Besides the outpost, the pod admits:
# - the paperless namespace, every port
# ../../infrastructure/network-policies/paperless-ns.yaml#paperless-admit
# - host / remote-node, every port
+8 -9
View File
@@ -4,9 +4,9 @@ metadata:
name: paperless-config
namespace: paperless
data:
# 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
# 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
# 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
@@ -344,8 +344,8 @@ spec:
- port: 80
targetPort: http
---
# Canonical paperless.renner.uno, gated by authentik-gate-sync (the
# authentik-gate Middleware). The docs.renner.uno alias lives in
# Canonical paperless.renner.uno, gated by authentik-gate-sync (ingress class
# authentik-gate). 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
@@ -354,18 +354,17 @@ metadata:
name: paperless
namespace: paperless
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
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: traefik
ingressClassName: authentik-gate
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 Authentik forward auth
# paths are served by a SECOND Ingress that sits behind the Authentik outpost
# (../wordpress/ingress.yaml#gated-paths). `/` is the ungated one.
url: https://www.renner.uno/
uptimeRobotConfig:
+12 -5
View File
@@ -37,9 +37,10 @@ spec:
hosts:
- www.renner.uno
---
# 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, 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 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
@@ -53,10 +54,9 @@ 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: traefik
ingressClassName: authentik-gate
tls:
- secretName: wordpress-tls
hosts:
@@ -65,6 +65,13 @@ 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 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: www.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -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 (the
# authentik-gate Middleware) is the only thing in front of it: Authentik SSO login, Zone
# 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
# Configurators only.
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -12,11 +12,10 @@ 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: traefik
ingressClassName: authentik-gate
rules:
- host: zone-configurator.home.renner.uno
http:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: crowdsec.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -67,22 +67,10 @@ spec:
parameters: ":8181"
# Survive the UDM's intermittent DNS outages (issue #393).
#
# The UDM's dnsmasq refuses connections in bursts — 330 "connection
# refused" errors in the last 3000 log lines here, on BOTH 10.2.40.1 and
# fd00::1. That alone would only be a blip, but it combines with a
# wildcard trap: the public renner.uno zone has a *.renner.uno CNAME to
# proxy.renner.uno that matches at EVERY depth, so renner.uno never
# returns NXDOMAIN. Any name that fails locally resolves publicly to the
# WAN proxy instead of failing honestly.
#
# The TTLs make it stick: the correct answer is a dnsmasq lease record
# with TTL 5, the wrong wildcard answer has TTL 300 — 60x more durable.
# One 5-second UDM blip therefore pins a wrong answer for 5 minutes.
#
# `success ... 60` clamps that 5s TTL up to a 60s floor, and
# `serve_stale 1h immediate` keeps answering from cache while the
# upstream is refusing connections, so a blip stops becoming a lookup
# failure that the wildcard can capture.
# The UDM's dnsmasq refuses connections in bursts and answers with
# 5s-TTL lease records; `success ... 60` raises the TTL to a 60s
# floor and `serve_stale 1h immediate` keeps serving through an
# upstream outage.
- name: cache
parameters: "300"
configBlock: |-
@@ -9,10 +9,12 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
# 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).
# 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.
# 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 authentik-server: auth.renner.uno and the gates'
# forward-auth requests to the embedded outpost.
# traefik reaches the authentik-server (auth.renner.uno) and the
# gate outposts living in this namespace.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
@@ -25,7 +25,7 @@ spec:
- ports:
- port: "8124"
protocol: TCP
# Gated backends (vscode, shelly-manager, esphome, zigbee2mqtt,
# Gate outposts (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
# Traefik to the alertmanager and prometheus gates is admitted by the CNPs
# The alertmanager and prometheus gate outposts are 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
# Traefik to the gated backends is admitted by the CNPs authentik-gate-sync generates
# The gate outposts are 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,11 +21,14 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless
# 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.
# 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.
#
# `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 through the gate) and by
# Service, /wp-admin and /wp-login.php to the gate outpost) 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
# Traefik for wp-admin is admitted by the CNP authentik-gate-sync
# The gate outpost 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.
@@ -65,9 +65,15 @@ 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:
+10 -16
View File
@@ -7,9 +7,9 @@ locals {
# www.renner.uno returns the public answer (proxy.renner.uno) — so this
# block was drift waiting to be re-applied.
#
# The correct end state is exactly this absence: www.renner.uno is labelled
# dns.renner.uno/public, so external-dns-unifi deliberately writes no UDM
# record and LAN clients follow the public path (wildcard -> proxy) and
# The correct end state is exactly this absence: www.renner.uno has no
# dns.renner.uno/internal label, so external-dns-unifi writes no UDM record
# and LAN clients follow the public path (explicit CNAME -> proxy) and
# hairpin back in. Re-adding an A record here would point the house at a
# server that is being decommissioned.
#
@@ -27,21 +27,15 @@ locals {
# AAAA Records
# UniFi auto-creates only the A record for the UDM's own hostname
# (udm -> 192.168.8.1) and forwards AAAA queries upstream, where the
# *.renner.uno wildcard answers with proxy.renner.uno (the public
# reverse proxy). IPv6-first clients — notably Home Assistant's UniFi
# Network integration — then connect to the proxy, which rejects the
# unknown SNI with `tlsv1 alert internal error`, flapping the
# integration into setup_error. Pin the AAAA to the UDM's own
# management ULA (fd00::1, serves the controller cert CN=udm.renner.uno
# and is reachable from the cluster via its v6 default route) so v6
# resolution stays local instead of falling through to the proxy.
# (udm -> 192.168.8.1). Pin the AAAA to the UDM's management ULA
# (fd00::1, serves the controller cert CN=udm.renner.uno) so IPv6-first
# clients such as Home Assistant's UniFi Network integration connect
# over v6.
"udm" = { type = "AAAA", value = "fd00::1" }
# Same failure mode as `udm` above: the UniFi captive-portal hostname
# auto-resolves to the UDM console (captive -> 192.168.8.1) over v4 but
# has no AAAA, so v6 queries fall through to the *.renner.uno wildcard
# (proxy.renner.uno). Pin the AAAA to the same UDM management ULA.
# The captive-portal hostname auto-resolves to the UDM console
# (captive -> 192.168.8.1) over v4 but has no AAAA; pin the AAAA to the
# same management ULA.
"captive" = { type = "AAAA", value = "fd00::1" }
}
}