562 lines
24 KiB
Python
562 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""authentik-gate-sync: the Authentik side of the gate Middleware.
|
|
|
|
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
|
|
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
|
|
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 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.
|
|
|
|
Annotations (all optional):
|
|
|
|
auth.renner.uno/group group whose members may pass; none = any user
|
|
auth.renner.uno/login-flow authentication flow slug; none = the brand's
|
|
auth.renner.uno/authorization-flow authorization flow slug (default below)
|
|
auth.renner.uno/skip-path provider skip_path_regex
|
|
|
|
Fail closed: until the provider exists, or after it is removed (e.g. the named
|
|
group is gone), the embedded outpost has no provider for the host and rejects
|
|
the request; Traefik answers 500 when the outpost is unreachable.
|
|
|
|
Ownership: Kubernetes objects carry app.kubernetes.io/managed-by; Authentik
|
|
applications carry meta_publisher. Anything without the marker is never touched.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
LOG = logging.getLogger("authentik-gate-sync")
|
|
|
|
AK_URL = os.environ.get("AK_URL", "http://authentik-server.authentik.svc.cluster.local").rstrip("/")
|
|
AK_TOKEN_FILE = os.environ.get("AK_TOKEN_FILE", "/var/run/secrets/authentik-gate-sync/token")
|
|
GATE_MIDDLEWARE = os.environ.get("GATE_MIDDLEWARE", "authentik-gate@kubernetescrd")
|
|
INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
|
|
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")
|
|
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
|
|
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
|
|
HTTP_TIMEOUT = 20
|
|
|
|
ANN = "auth.renner.uno/"
|
|
MANAGER = "authentik-gate-sync"
|
|
MANAGED_BY = {"app.kubernetes.io/managed-by": MANAGER}
|
|
GATE_LABEL = "auth.renner.uno/gate"
|
|
EMBEDDED_OUTPOST = "goauthentik.io/outposts/embedded"
|
|
TRAEFIK = {"k8s:io.kubernetes.pod.namespace": INGRESS_NS, "app.kubernetes.io/name": "traefik"}
|
|
MIDDLEWARES_ANN = "traefik.ingress.kubernetes.io/router.middlewares"
|
|
|
|
K8S_API = "https://kubernetes.default.svc"
|
|
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|
K8S_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
|
|
|
|
|
class ApiError(Exception):
|
|
def __init__(self, msg, code=None):
|
|
super().__init__(msg)
|
|
self.code = code
|
|
|
|
|
|
class GateError(Exception):
|
|
"""A source Ingress that cannot be turned into a gate; it gets no route."""
|
|
|
|
|
|
def http(method, url, body=None, headers=None, cafile=None, content_type="application/json"):
|
|
data = None if body is None else json.dumps(body).encode()
|
|
req = urllib.request.Request(url, data=data, method=method)
|
|
req.add_header("Accept", "application/json")
|
|
if data is not None:
|
|
req.add_header("Content-Type", content_type)
|
|
for k, v in (headers or {}).items():
|
|
req.add_header(k, v)
|
|
ctx = None
|
|
if cafile:
|
|
import ssl
|
|
ctx = ssl.create_default_context(cafile=cafile)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
|
|
raw = resp.read()
|
|
return json.loads(raw) if raw else None
|
|
except urllib.error.HTTPError as e:
|
|
detail = e.read().decode(errors="replace")[:500]
|
|
raise ApiError(f"{method} {url} -> {e.code}: {detail}", e.code) from None
|
|
except (OSError, ValueError) as e: # URLError, timeouts, resets, bad JSON
|
|
raise ApiError(f"{method} {url} -> {e}") from None
|
|
|
|
|
|
# --- desired state (pure) ---------------------------------------------------
|
|
|
|
def gate_name(ing):
|
|
"""<ns>-<ingress>, truncated, plus a hash of both: unambiguous (a-b/c vs a/b-c)
|
|
and short enough for label values (63)."""
|
|
ns, name = ing["metadata"]["namespace"], ing["metadata"]["name"]
|
|
digest = hashlib.sha256(f"{ns}/{name}".encode()).hexdigest()[:6]
|
|
return f"{ns}-{name}"[:45].rstrip("-") + "-" + digest
|
|
|
|
|
|
def group_expression(group):
|
|
# repr() makes the annotation value a Python string literal, never code.
|
|
# A group that does not exist matches nothing, so deleting it denies access.
|
|
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"]
|
|
ann = meta.get("annotations") or {}
|
|
rules = ing.get("spec", {}).get("rules") or []
|
|
hosts = sorted({r.get("host") for r in rules if r.get("host")})
|
|
if len(hosts) != 1 or len(hosts) != len(rules):
|
|
raise GateError(f"needs exactly one rule with one host, has {[r.get('host') for r in rules]}")
|
|
if hosts[0].startswith("*"):
|
|
raise GateError(f"wildcard host {hosts[0]} cannot be a gate")
|
|
backends = set()
|
|
for p in rules[0].get("http", {}).get("paths") or []:
|
|
svc = (p.get("backend") or {}).get("service")
|
|
if not svc:
|
|
raise GateError("every path needs a service backend")
|
|
port = svc.get("port") or {}
|
|
backends.add((svc["name"], port.get("number") or port.get("name")))
|
|
if ing.get("spec", {}).get("defaultBackend"):
|
|
raise GateError("defaultBackend is not supported")
|
|
if len(backends) != 1:
|
|
raise GateError(f"all paths must share one backend, has {sorted(map(str, backends))}")
|
|
(service, port), = backends
|
|
tls_hosts = {h for t in ing.get("spec", {}).get("tls") or [] for h in t.get("hosts") or []}
|
|
return {
|
|
"name": gate_name(ing),
|
|
"namespace": meta["namespace"],
|
|
"ingress": meta["name"],
|
|
"uid": meta["uid"],
|
|
"host": hosts[0],
|
|
"scheme": "https" if hosts[0] in tls_hosts else "http",
|
|
"service": service,
|
|
"port": port,
|
|
"group": ann.get(ANN + "group") or None,
|
|
"login_flow": ann.get(ANN + "login-flow") or None,
|
|
"authorization_flow": ann.get(ANN + "authorization-flow") or DEFAULT_AUTHZ_FLOW,
|
|
"skip_path": ann.get(ANN + "skip-path", ""),
|
|
}
|
|
|
|
|
|
def owner_ref(gate):
|
|
return [{"apiVersion": "networking.k8s.io/v1", "kind": "Ingress", "name": gate["ingress"],
|
|
"uid": gate["uid"], "controller": True, "blockOwnerDeletion": False}]
|
|
|
|
|
|
def gate_labels(gate):
|
|
return {**MANAGED_BY, GATE_LABEL: gate["name"]}
|
|
|
|
|
|
def backend_ingress_cnp(gate, pod_selector, target_port):
|
|
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
|
|
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
|
|
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
|
|
"spec": {"endpointSelector": {"matchLabels": pod_selector},
|
|
"ingress": [{"fromEndpoints": [{"matchLabels": TRAEFIK}],
|
|
"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
|
|
selected pods' container ports, so the policy does not depend on the pods
|
|
being up."""
|
|
selector = service.get("spec", {}).get("selector")
|
|
if not selector:
|
|
raise GateError(f"Service {gate['service']} has no selector; cannot scope its network policy")
|
|
sp = None
|
|
for p in service["spec"].get("ports") or []:
|
|
if p.get("port") == gate["port"] or (p.get("name") and p.get("name") == gate["port"]):
|
|
sp = p
|
|
break
|
|
if sp is None:
|
|
raise GateError(f"Service {gate['service']} has no port {gate['port']}")
|
|
target = sp.get("targetPort", sp["port"])
|
|
if isinstance(target, int):
|
|
return sp["port"], selector, target
|
|
if isinstance(target, str) and target.isdigit():
|
|
return sp["port"], selector, int(target)
|
|
return sp["port"], selector, target
|
|
|
|
|
|
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, flows):
|
|
return {
|
|
"name": gate["name"],
|
|
"mode": "forward_single",
|
|
"external_host": f"{gate['scheme']}://{gate['host']}",
|
|
"internal_host": "",
|
|
"cookie_domain": COOKIE_DOMAIN,
|
|
"access_token_validity": "hours=24",
|
|
"refresh_token_validity": "days=30",
|
|
"intercept_header_auth": True,
|
|
"skip_path_regex": gate["skip_path"],
|
|
"authorization_flow": flows[gate["authorization_flow"]],
|
|
"authentication_flow": flows[gate["login_flow"]] if gate["login_flow"] else None,
|
|
"invalidation_flow": flows[INVALIDATION_FLOW],
|
|
}
|
|
|
|
|
|
def drift(current, desired):
|
|
return {k: v for k, v in desired.items() if current.get(k) != v}
|
|
|
|
|
|
# --- Kubernetes -------------------------------------------------------------
|
|
|
|
class K8s:
|
|
def _h(self):
|
|
with open(K8S_TOKEN_FILE) as f:
|
|
return {"Authorization": f"Bearer {f.read().strip()}"}
|
|
|
|
def get(self, path):
|
|
return http("GET", K8S_API + path, headers=self._h(), cafile=K8S_CA_FILE)
|
|
|
|
def apply(self, obj):
|
|
"""Server-side apply; idempotent, so every pass re-asserts every object.
|
|
Refuses to take over a same-named object this controller does not own."""
|
|
try:
|
|
live = self.get(object_path(obj))
|
|
except ApiError as e:
|
|
if e.code != 404:
|
|
raise
|
|
else:
|
|
if (live["metadata"].get("labels") or {}).get("app.kubernetes.io/managed-by") != MANAGER:
|
|
raise GateError(f'{obj["kind"]} {obj["metadata"]["namespace"]}/{obj["metadata"]["name"]} '
|
|
f"exists and is not managed by {MANAGER}")
|
|
path = object_path(obj) + f"?fieldManager={MANAGER}&force=true"
|
|
return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE,
|
|
content_type="application/apply-patch+yaml")
|
|
|
|
def delete(self, obj):
|
|
try:
|
|
http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE)
|
|
except ApiError as e:
|
|
if e.code != 404:
|
|
raise
|
|
|
|
# Swept by label: any object of these kinds marked as this controller's that no
|
|
# gate wants is deleted. It creates only CNPs; Ingresses and Services carrying
|
|
# the label are routes a gate must not have besides its own Ingress.
|
|
COLLECTIONS = {
|
|
"Ingress": "/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses",
|
|
"Service": "/api/v1/namespaces/{ns}/services",
|
|
"CiliumNetworkPolicy": "/apis/cilium.io/v2/namespaces/{ns}/ciliumnetworkpolicies",
|
|
}
|
|
CLUSTER_COLLECTIONS = {
|
|
"Ingress": "/apis/networking.k8s.io/v1/ingresses",
|
|
"Service": "/api/v1/services",
|
|
"CiliumNetworkPolicy": "/apis/cilium.io/v2/ciliumnetworkpolicies",
|
|
}
|
|
|
|
|
|
def object_path(obj):
|
|
m = obj["metadata"]
|
|
return COLLECTIONS[obj["kind"]].format(ns=m["namespace"]) + "/" + m["name"]
|
|
|
|
|
|
def managed_objects(k8s):
|
|
sel = urllib.parse.quote(f"app.kubernetes.io/managed-by={MANAGER}")
|
|
out = []
|
|
for kind, path in CLUSTER_COLLECTIONS.items():
|
|
for item in k8s.get(f"{path}?labelSelector={sel}").get("items", []):
|
|
item["kind"] = kind
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
# --- Authentik --------------------------------------------------------------
|
|
|
|
class Authentik:
|
|
def __init__(self, token):
|
|
self.h = {"Authorization": f"Bearer {token}"}
|
|
|
|
def req(self, method, path, body=None):
|
|
return http(method, f"{AK_URL}/api/v3/{path}", body, headers=self.h)
|
|
|
|
def list(self, path, **query):
|
|
"""All pages of a list endpoint."""
|
|
query = {**query, "page_size": 100}
|
|
out, page = [], 1
|
|
while True:
|
|
res = self.req("GET", f"{path}?{urllib.parse.urlencode({**query, 'page': page})}")
|
|
out.extend(res["results"])
|
|
if not res["pagination"].get("next"):
|
|
return out
|
|
page += 1
|
|
|
|
def one(self, path, **query):
|
|
found = self.list(path, **query)
|
|
return found[0] if found else None
|
|
|
|
def get(self, path):
|
|
try:
|
|
return self.req("GET", path)
|
|
except ApiError as e:
|
|
if e.code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
def get_application(ak, slug):
|
|
# Retrieve, never list: the list endpoint only shows applications the token's
|
|
# user passes policies for and caches that per user, ignoring the filters.
|
|
return ak.get(f"core/applications/{slug}/")
|
|
|
|
|
|
def embedded_outpost(ak):
|
|
# Matched again here: a filter the server ignored must not select another outpost.
|
|
found = [o for o in ak.list("outposts/instances/", managed__iexact=EMBEDDED_OUTPOST)
|
|
if o.get("managed") == EMBEDDED_OUTPOST]
|
|
if len(found) != 1:
|
|
raise ApiError(f"expected one embedded outpost, found {len(found)}")
|
|
return found[0]
|
|
|
|
|
|
def ensure_embedded_config(ak):
|
|
outpost = embedded_outpost(ak)
|
|
want = embedded_config(outpost.get("config"))
|
|
if outpost.get("config") != want:
|
|
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", {"config": want})
|
|
LOG.info("embedded outpost: updated config")
|
|
|
|
|
|
def ensure_gate(ak, gate, flows):
|
|
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")
|
|
|
|
app = get_application(ak, name)
|
|
if app is not None and app.get("meta_publisher") != MANAGER:
|
|
raise GateError(f"application {name} exists and is not managed by {MANAGER}")
|
|
|
|
want = provider_body(gate, flows)
|
|
provider = ak.one("providers/proxy/", name__iexact=name)
|
|
if provider is None:
|
|
provider = ak.req("POST", "providers/proxy/", want)
|
|
LOG.info("%s: created provider %s", name, provider["pk"])
|
|
elif provider.get("assigned_application_slug") not in (None, name) or \
|
|
(app is not None and app.get("provider") != provider["pk"]):
|
|
raise GateError(f"proxy provider {name} belongs to another application")
|
|
elif drift(provider, want):
|
|
# Also adopts a provider whose application an interrupted teardown removed.
|
|
# The full body: the serializer validates internal_host against mode
|
|
# from the request alone, so a partial PATCH without them is a 400.
|
|
ak.req("PATCH", f"providers/proxy/{provider['pk']}/", want)
|
|
LOG.info("%s: updated provider %s", name, sorted(drift(provider, want)))
|
|
|
|
want_app = {"name": f"{gate['host']} (gate)", "slug": name, "provider": provider["pk"],
|
|
"meta_launch_url": want["external_host"], "meta_publisher": MANAGER,
|
|
"meta_description": f"Gate for Ingress {gate['namespace']}/{gate['ingress']}"}
|
|
if app is None:
|
|
app = ak.req("POST", "core/applications/", want_app)
|
|
LOG.info("%s: created application", name)
|
|
elif drift(app, want_app):
|
|
ak.req("PATCH", f"core/applications/{name}/", drift(app, want_app))
|
|
LOG.info("%s: updated application %s", name, sorted(drift(app, want_app)))
|
|
|
|
ensure_access(ak, gate, app["pbm_uuid"])
|
|
|
|
# Re-read per gate: the providers list is replaced whole.
|
|
embedded = embedded_outpost(ak)
|
|
if provider["pk"] not in (embedded.get("providers") or []):
|
|
ak.req("PATCH", f"outposts/instances/{embedded['pk']}/",
|
|
{"providers": [*(embedded.get("providers") or []), provider["pk"]]})
|
|
LOG.info("%s: attached provider to the embedded outpost", name)
|
|
remove_dedicated_outposts(ak, name, provider["pk"])
|
|
|
|
|
|
def remove_dedicated_outposts(ak, name, provider):
|
|
"""Outposts named after the gate that serve only its provider: the embedded
|
|
outpost serves every gate, so Authentik tears these and their pods down."""
|
|
for o in ak.list("outposts/instances/", name__iexact=name):
|
|
if o.get("providers") == [provider] and not o.get("managed"):
|
|
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
|
|
LOG.info("%s: removed dedicated outpost", name)
|
|
|
|
|
|
def ensure_access(ak, gate, target):
|
|
"""Group gates bind an expression policy, not the group itself: a deleted
|
|
group cascades its bindings away (leaving the app open to everyone), while
|
|
the policy keeps evaluating False. The new binding exists before any stale
|
|
one is removed, so a change never passes through "no binding"."""
|
|
name = gate["name"]
|
|
policy = None
|
|
if gate["group"]:
|
|
want = {"name": name, "expression": group_expression(gate["group"]), "execution_logging": False}
|
|
policy = ak.one("policies/expression/", name=name)
|
|
if policy is None:
|
|
policy = ak.req("POST", "policies/expression/", want)
|
|
LOG.info("%s: created policy for group %s", name, gate["group"])
|
|
elif drift(policy, want):
|
|
ak.req("PATCH", f"policies/expression/{policy['pk']}/", drift(policy, want))
|
|
LOG.info("%s: policy now requires group %s", name, gate["group"])
|
|
|
|
bindings = ak.list("policies/bindings/", target=target)
|
|
keep = next((b for b in bindings if policy and b.get("policy") == policy["pk"]), None)
|
|
want_binding = {"enabled": True, "negate": False, "failure_result": False}
|
|
if policy and keep is None:
|
|
ak.req("POST", "policies/bindings/", {"target": target, "policy": policy["pk"], "order": 0,
|
|
"timeout": 30, **want_binding})
|
|
LOG.info("%s: bound policy", name)
|
|
elif keep is not None and drift(keep, want_binding):
|
|
ak.req("PATCH", f"policies/bindings/{keep['pk']}/", want_binding)
|
|
for b in bindings:
|
|
if b is not keep:
|
|
ak.req("DELETE", f"policies/bindings/{b['pk']}/")
|
|
LOG.info("%s: removed binding %s", name, b["pk"])
|
|
if not gate["group"]:
|
|
stale = ak.one("policies/expression/", name=name)
|
|
if stale is not None:
|
|
ak.req("DELETE", f"policies/expression/{stale['pk']}/")
|
|
|
|
|
|
def remove_gate(ak, app):
|
|
"""Delete an owned gate: the application (bindings cascade; its provider is
|
|
PROTECTed while referenced), then the provider (which leaves the embedded
|
|
outpost with it), the policy. Ownership is found through the application,
|
|
so until it is gone a failed step is retried on the next pass."""
|
|
name, provider = app["slug"], app.get("provider")
|
|
remove_dedicated_outposts(ak, name, provider)
|
|
ak.req("DELETE", f"core/applications/{name}/")
|
|
if provider:
|
|
ak.req("DELETE", f"providers/proxy/{provider}/")
|
|
policy = ak.one("policies/expression/", name=name)
|
|
if policy is not None:
|
|
ak.req("DELETE", f"policies/expression/{policy['pk']}/")
|
|
LOG.info("%s: removed gate from Authentik", name)
|
|
|
|
|
|
def owned_applications(ak):
|
|
"""Applications this controller owns, found through the (unfiltered) proxy
|
|
provider list and confirmed one by one."""
|
|
for p in ak.list("providers/proxy/"):
|
|
slug = p.get("assigned_application_slug")
|
|
if not slug:
|
|
continue
|
|
app = get_application(ak, slug)
|
|
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
|
|
yield app
|
|
|
|
|
|
# --- reconcile --------------------------------------------------------------
|
|
|
|
def reconcile():
|
|
k8s = K8s()
|
|
with open(AK_TOKEN_FILE) as f:
|
|
ak = Authentik(f.read().strip())
|
|
|
|
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
|
|
shared = shared_hosts(sources)
|
|
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
|
|
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
|
|
held = set() # gates hit by an API error: their live objects stay as they are
|
|
failed = 0
|
|
for ing in sorted(sources, key=gate_name):
|
|
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")
|
|
ns = gate["namespace"]
|
|
try:
|
|
service = k8s.get(f"/api/v1/namespaces/{ns}/services/{gate['service']}")
|
|
except ApiError as e:
|
|
if e.code == 404:
|
|
raise GateError(f"backend Service {gate['service']} does not exist") from None
|
|
raise
|
|
_, pods, target_port = resolve_backend(gate, service)
|
|
gates.add(gate["name"])
|
|
ensure_gate(ak, gate, flows)
|
|
cnp = backend_ingress_cnp(gate, pods, target_port)
|
|
k8s.apply(cnp)
|
|
desired.add((cnp["kind"], cnp["metadata"]["namespace"], cnp["metadata"]["name"]))
|
|
except GateError as e:
|
|
# The Ingress cannot be gated as written: its provider goes with the
|
|
# sweep below, after which the outpost rejects the host.
|
|
LOG.error("%s: %s", ref, e)
|
|
failed += 1
|
|
gates.discard(gate_name(ing))
|
|
except Exception as e:
|
|
# Transient: leave this gate exactly as the last good pass left it.
|
|
LOG.error("%s: %s", ref, e)
|
|
gates.add(gate_name(ing))
|
|
held.add(gate_name(ing))
|
|
failed += 1
|
|
|
|
removed = 0
|
|
for obj in managed_objects(k8s):
|
|
key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
|
|
if key not in desired and obj["metadata"]["labels"].get(GATE_LABEL) not in held:
|
|
obj = {"kind": obj["kind"], "metadata": {"namespace": key[1], "name": key[2]}}
|
|
k8s.delete(obj)
|
|
LOG.info("deleted %s %s/%s", *key)
|
|
removed += 1
|
|
|
|
for app in list(owned_applications(ak)):
|
|
if app["slug"] not in gates:
|
|
remove_gate(ak, app)
|
|
removed += 1
|
|
|
|
LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed)
|
|
|
|
|
|
def main():
|
|
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), stream=sys.stdout,
|
|
format="%(asctime)s %(levelname)s %(message)s")
|
|
if os.environ.get("ONCE") == "1":
|
|
reconcile()
|
|
return
|
|
while True:
|
|
try:
|
|
reconcile()
|
|
except Exception as e: # keep running; the next pass retries
|
|
LOG.error("reconcile failed: %s", e)
|
|
time.sleep(INTERVAL)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|