From 4580dec06b0c5ea7811e23e2e4b00a9229e0ca04 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Wed, 23 Apr 2025 18:22:10 +0200 Subject: [PATCH] outposts: add support for gateway API (#13272) --- authentik/outposts/models.py | 2 + .../proxy/controllers/k8s/httproute.py | 234 ++++++++++++++++++ .../providers/proxy/controllers/kubernetes.py | 3 + 3 files changed, 239 insertions(+) create mode 100644 authentik/providers/proxy/controllers/k8s/httproute.py diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 4032892fe8..1d6a8af1bb 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -74,6 +74,8 @@ class OutpostConfig: kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict) kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls") kubernetes_ingress_class_name: str | None = field(default=None) + kubernetes_httproute_annotations: dict[str, str] = field(default_factory=dict) + kubernetes_httproute_parent_refs: list[dict[str, str]] = field(default_factory=list) kubernetes_service_type: str = field(default="ClusterIP") kubernetes_disabled_components: list[str] = field(default_factory=list) kubernetes_image_pull_secrets: list[str] = field(default_factory=list) diff --git a/authentik/providers/proxy/controllers/k8s/httproute.py b/authentik/providers/proxy/controllers/k8s/httproute.py new file mode 100644 index 0000000000..a6fcf3a32c --- /dev/null +++ b/authentik/providers/proxy/controllers/k8s/httproute.py @@ -0,0 +1,234 @@ +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from dacite.core import from_dict +from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi, V1ObjectMeta + +from authentik.outposts.controllers.base import FIELD_MANAGER +from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler +from authentik.outposts.controllers.k8s.triggers import NeedsUpdate +from authentik.outposts.controllers.kubernetes import KubernetesController +from authentik.providers.proxy.models import ProxyMode, ProxyProvider + +if TYPE_CHECKING: + from authentik.outposts.controllers.kubernetes import KubernetesController + + +@dataclass(slots=True) +class RouteBackendRef: + name: str + port: int + + +@dataclass(slots=True) +class RouteSpecParentRefs: + name: str + sectionName: str | None = None + port: int | None = None + namespace: str | None = None + kind: str = "Gateway" + group: str = "gateway.networking.k8s.io" + + +@dataclass(slots=True) +class HTTPRouteSpecRuleMatchPath: + type: str + value: str + + +@dataclass(slots=True) +class HTTPRouteSpecRuleMatchHeader: + name: str + value: str + type: str = "Exact" + + +@dataclass(slots=True) +class HTTPRouteSpecRuleMatch: + path: HTTPRouteSpecRuleMatchPath + headers: list[HTTPRouteSpecRuleMatchHeader] + + +@dataclass(slots=True) +class HTTPRouteSpecRule: + backendRefs: list[RouteBackendRef] + matches: list[HTTPRouteSpecRuleMatch] + + +@dataclass(slots=True) +class HTTPRouteSpec: + parentRefs: list[RouteSpecParentRefs] + hostnames: list[str] + rules: list[HTTPRouteSpecRule] + + +@dataclass(slots=True) +class HTTPRouteMetadata: + name: str + namespace: str + annotations: dict = field(default_factory=dict) + labels: dict = field(default_factory=dict) + + +@dataclass(slots=True) +class HTTPRoute: + apiVersion: str + kind: str + metadata: HTTPRouteMetadata + spec: HTTPRouteSpec + + +class HTTPRouteReconciler(KubernetesObjectReconciler): + """Kubernetes Gateway API HTTPRoute Reconciler""" + + def __init__(self, controller: "KubernetesController") -> None: + super().__init__(controller) + self.api_ex = ApiextensionsV1Api(controller.client) + self.api = CustomObjectsApi(controller.client) + self.crd_group = "gateway.networking.k8s.io" + self.crd_version = "v1" + self.crd_plural = "httproutes" + + @staticmethod + def reconciler_name() -> str: + return "httproute" + + @property + def noop(self) -> bool: + if not self.crd_exists(): + self.logger.debug("CRD doesn't exist") + return True + if not self.controller.outpost.config.kubernetes_httproute_parent_refs: + self.logger.debug("HTTPRoute parentRefs not set.") + return True + return False + + def crd_exists(self) -> bool: + """Check if the Gateway API resources exists""" + return bool( + len( + self.api_ex.list_custom_resource_definition( + field_selector=f"metadata.name={self.crd_plural}.{self.crd_group}" + ).items + ) + ) + + def reconcile(self, current: HTTPRoute, reference: HTTPRoute): + super().reconcile(current, reference) + if current.metadata.annotations != reference.metadata.annotations: + raise NeedsUpdate() + if current.spec.parentRefs != reference.spec.parentRefs: + raise NeedsUpdate() + if current.spec.hostnames != reference.spec.hostnames: + raise NeedsUpdate() + if current.spec.rules != reference.spec.rules: + raise NeedsUpdate() + + def get_object_meta(self, **kwargs) -> V1ObjectMeta: + return super().get_object_meta( + **kwargs, + ) + + def get_reference_object(self) -> HTTPRoute: + hostnames = [] + rules = [] + + for proxy_provider in ProxyProvider.objects.filter(outpost__in=[self.controller.outpost]): + proxy_provider: ProxyProvider + external_host_name = urlparse(proxy_provider.external_host) + if proxy_provider.mode in [ProxyMode.FORWARD_SINGLE, ProxyMode.FORWARD_DOMAIN]: + rule = HTTPRouteSpecRule( + backendRefs=[RouteBackendRef(name=self.name, port=9000)], + matches=[ + HTTPRouteSpecRuleMatch( + headers=[ + HTTPRouteSpecRuleMatchHeader( + name="Host", + value=external_host_name.hostname, + ) + ], + path=HTTPRouteSpecRuleMatchPath( + type="PathPrefix", value="/outpost.goauthentik.io" + ), + ) + ], + ) + else: + rule = HTTPRouteSpecRule( + backendRefs=[RouteBackendRef(name=self.name, port=9000)], + matches=[ + HTTPRouteSpecRuleMatch( + headers=[ + HTTPRouteSpecRuleMatchHeader( + name="Host", + value=external_host_name.hostname, + ) + ], + path=HTTPRouteSpecRuleMatchPath(type="PathPrefix", value="/"), + ) + ], + ) + hostnames.append(external_host_name.hostname) + rules.append(rule) + + return HTTPRoute( + apiVersion=f"{self.crd_group}/{self.crd_version}", + kind="HTTPRoute", + metadata=HTTPRouteMetadata( + name=self.name, + namespace=self.namespace, + annotations=self.controller.outpost.config.kubernetes_httproute_annotations, + labels=self.get_object_meta().labels, + ), + spec=HTTPRouteSpec( + parentRefs=[ + from_dict(RouteSpecParentRefs, spec) + for spec in self.controller.outpost.config.kubernetes_httproute_parent_refs + ], + hostnames=hostnames, + rules=rules, + ), + ) + + def create(self, reference: HTTPRoute): + return self.api.create_namespaced_custom_object( + group=self.crd_group, + version=self.crd_version, + plural=self.crd_plural, + namespace=self.namespace, + body=asdict(reference), + field_manager=FIELD_MANAGER, + ) + + def delete(self, reference: HTTPRoute): + return self.api.delete_namespaced_custom_object( + group=self.crd_group, + version=self.crd_version, + plural=self.crd_plural, + namespace=self.namespace, + name=self.name, + ) + + def retrieve(self) -> HTTPRoute: + return from_dict( + HTTPRoute, + self.api.get_namespaced_custom_object( + group=self.crd_group, + version=self.crd_version, + plural=self.crd_plural, + namespace=self.namespace, + name=self.name, + ), + ) + + def update(self, current: HTTPRoute, reference: HTTPRoute): + return self.api.patch_namespaced_custom_object( + group=self.crd_group, + version=self.crd_version, + plural=self.crd_plural, + namespace=self.namespace, + name=self.name, + body=asdict(reference), + field_manager=FIELD_MANAGER, + ) diff --git a/authentik/providers/proxy/controllers/kubernetes.py b/authentik/providers/proxy/controllers/kubernetes.py index 73b0ea8bc0..43c00a53c5 100644 --- a/authentik/providers/proxy/controllers/kubernetes.py +++ b/authentik/providers/proxy/controllers/kubernetes.py @@ -3,6 +3,7 @@ from authentik.outposts.controllers.base import DeploymentPort from authentik.outposts.controllers.kubernetes import KubernetesController from authentik.outposts.models import KubernetesServiceConnection, Outpost +from authentik.providers.proxy.controllers.k8s.httproute import HTTPRouteReconciler from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler from authentik.providers.proxy.controllers.k8s.traefik import TraefikMiddlewareReconciler @@ -18,8 +19,10 @@ class ProxyKubernetesController(KubernetesController): DeploymentPort(9443, "https", "tcp"), ] self.reconcilers[IngressReconciler.reconciler_name()] = IngressReconciler + self.reconcilers[HTTPRouteReconciler.reconciler_name()] = HTTPRouteReconciler self.reconcilers[TraefikMiddlewareReconciler.reconciler_name()] = ( TraefikMiddlewareReconciler ) self.reconcile_order.append(IngressReconciler.reconciler_name()) + self.reconcile_order.append(HTTPRouteReconciler.reconciler_name()) self.reconcile_order.append(TraefikMiddlewareReconciler.reconciler_name())