Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e12e90509 | ||
|
|
613e1da011 | ||
|
|
6998845854 | ||
|
|
430c710fd0 | ||
|
|
6accd1d3cd | ||
|
|
6178063299 | ||
|
|
0b7a2b6c46 | ||
|
|
772311c574 | ||
|
|
7bf1681c2d | ||
|
|
c9cd0783df | ||
|
|
7e57b42316 | ||
|
|
9afb56536c | ||
|
|
23c089c8b7 | ||
|
|
a3eb69ee20 | ||
|
|
c6cb6b3aec | ||
|
|
29c299c0fe | ||
|
|
a3efc511c8 | ||
|
|
924b6e9e88 | ||
|
|
08004a508a | ||
|
|
3d3510b7bd | ||
|
|
bd9f0cb18d |
@@ -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
|
||||
|
||||
@@ -157,6 +157,29 @@
|
||||
ansible.builtin.pause:
|
||||
seconds: 10
|
||||
|
||||
# The host's mesh client must not manage DNS: containers here resolve through
|
||||
# the host stub, and the renner.uno nameserver group is unreachable from it.
|
||||
- name: Check for the host NetBird client
|
||||
ansible.builtin.stat:
|
||||
path: /usr/bin/netbird
|
||||
register: _netbird_client
|
||||
|
||||
- name: Read the host NetBird client config
|
||||
ansible.builtin.command: netbird debug config
|
||||
register: _netbird_client_config
|
||||
changed_when: false
|
||||
when: _netbird_client.stat.exists
|
||||
|
||||
# disable-dns is only applied on a fresh `up`, not to a connected client.
|
||||
- name: Reconnect the host NetBird client with DNS management disabled
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
netbird down
|
||||
netbird up --disable-dns
|
||||
when:
|
||||
- _netbird_client.stat.exists
|
||||
- not (_netbird_client_config.stdout | from_json).disableDns
|
||||
|
||||
- name: Display deployment status
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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: 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
|
||||
@@ -170,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,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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,25 +8,25 @@
|
||||
#
|
||||
# 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:
|
||||
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: authentik-gate
|
||||
ingressClassName: traefik
|
||||
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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, 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
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# (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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
@@ -24,13 +22,14 @@ 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
|
||||
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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -354,17 +354,18 @@ 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: 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
+10
-16
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user