Вход в Kubernetes по 2FA: как мы связали Gateway API, Dex и MULTIDIRECTORY

У нас начали активно появляться кластеры на Talos Linux, в том числе и небольшая песочница для тестирования продуктов, о которой пойдёт речь далее. Доступы раздавались через обмен рутовыми kubeconfig'ами в yopass. Быстро, привычно и, к сожалению, абсолютно неуправляемо.
Проблема даже не в самом факте передачи файла. Проблема в том, что лежит внутри: клиентский сертификат, который выписан на год, работает всегда и отовсюду, не привязан ни к какому конкретному человеку и не отзывается ничем, кроме ротации CA всего кластера. Сотрудник уходит из компании — его kubeconfig продолжает работать. Ноутбук потеряли — конфиг продолжает работать. Кто-то выполнил kubectl delete не в том кластере — найти его не удастся. Blameless culture в чистом виде.
Хотелось получить другое поведение:
вход в кластер — по корпоративной учётной записи, а не по файлу;
обязательный второй фактор;
права в кластере, которые определяются группами пользователя;
kubeconfig, который не страшно потерять, потому что внутри него никаких секретов нет.
Ниже — что в итоге получилось и с какими проблемами нам пришлось столкнуться. Отдельно немного затронем Gateway API и сложности, которые могут возникнуть, если вы до этого использовали только Istio или NGINX Ingress.
Стек

Коротко по компонентам:
Компонент | Роль |
|---|---|
Talos Linux 1.13 | Иммутабельная ОС под Kubernetes, управляется только через API |
Kubernetes v1.33 | Собственно кластер |
ArgoCD | GitOps: всё состояние кластера описано в репозитории (app-of-apps) |
MetalLB | Выдаёт LoadBalancer-адрес на bare-metal. Только для dev-контура |
NGINX Gateway Fabric 2.6 | Реализация Gateway API, точка входа снаружи |
Cert-manager 1.21 | Wildcard-сертификат Let's Encrypt через DNS-01 |
Dex 0.24 | OIDC-провайдер, «переходник» между LDAP и OIDC |
kube-oidc-proxy | Проверяет токен и ходит в API-сервер через impersonation |
MULTIDIRECTORY | Служба каталогов: пользователи, группы и 2FA |
Свой сервис на Python | Отдаёт готовый |
Отдельно отметим то, чего в списке нет: мы не трогали флаги kube-apiserver. Это осознанное решение, к нему вернёмся в разделе про kube-oidc-proxy.
MULTIDIRECTORY как источник правды
Ключевая идея всей конструкции: кластер не хранит пользователей. Вообще. В Kubernetes нет объекта User, и это, как ни странно, удобно — значит, единственным источником правды будет служба каталогов.
Мы используем MULTIDIRECTORY — нашу службу каталогов, которую разрабатываем как замену Microsoft AD. Для этой задачи важны две её особенности.
Первая — обычный LDAP-интерфейс. Dex подключается к ней стандартным LDAP-коннектором, без плагинов и модулей:
- type: ldap
id: ldap
name: LDAP (Multifactor)
config:
host: multidirectory.ru:939
bindDN: "cn=dex,cn=users,dc=multifactor,dc=ru"
bindPW: "{{.Env.LDAP_BIND_PASSWORD}}"
userSearch:
baseDN: "cn=users,dc=multifactor,dc=ru"
username: cn
idAttr: cn
emailAttr: mail
preferredUsernameAttr: cn
groupSearch:
baseDN: "cn=groups,dc=multifactor,dc=ru"
userMatchers:
- userAttr: dn
groupAttr: member
nameAttr: cnВторая, и более интересная — второй фактор настраивается на стороне службы каталогов, а не на стороне Kubernetes. Политика доступа с условной двухфакторной аутентификацией живёт в MULTIDIRECTORY: можно включить 2FA для группы администраторов и не включать для сервисных учёток.
Практический вывод: в кластере нет ни строчки конфигурации, связанной с 2FA. Dex не знает, что второй фактор существует, kube-oidc-proxy не знает, kubectl тем более не знает. Для них это обычный OIDC-логин, который просто занимает на несколько секунд дольше обычного. Всё, что нужно для включения 2FA на вход в кластер, делается в интерфейсе службы каталогов.

Группы LDAP → права в кластере
Самая приятная часть. Dex складывает группы пользователя в claim groups внутри токена, kube-oidc-proxy передаёт их в API-сервер, а дальше работает штатный RBAC — потому что ClusterRoleBinding умеет ссылаться на субъект типа Group:
# Read-only для участников cn=dex,cn=groups,dc=multifactor,dc=ru
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dex-cluster-name
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: Group
name: dex
apiGroup: rbac.authorization.k8s.ioЧто это даёт на практике. Выдать новому инженеру доступ к кластеру — значит добавить его в группу в службе каталогов. Забрать доступ — убрать из группы. Никаких kubectl вообще, никаких изменений в Git, никакой пересборки конфигов. Увольнение сотрудника блокирует ему доступ во все кластеры сразу, потому что блокируется учётная запись, а не выданный когда-то файл.
Отдельный бонус — аудит. В логе API-сервера видно конкретного человека, а не общий kubernetes-admin.
Один хост, три бэкенда
Снаружи весь процесс аутентификации живёт на одном FQDN — cluster-name-dex.multifactor.dev. Это осознанное решение: одна DNS-запись, один кластер.
Разводит запросы HTTPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: dex
namespace: dex
spec:
parentRefs:
- name: https
namespace: nginx-gateway
hostnames:
- cluster-name-dex.multifactor.dev
rules:
# OIDC-эндпоинты самого Dex
- matches:
- path: { type: PathPrefix, value: /dex }
backendRefs:
- name: dex
port: 5556
# Ссылка на kubeconfig
- matches:
- path: { type: PathPrefix, value: /kubeconfig }
backendRefs:
- name: kubeconfig-generator
port: 8080
# Всё остальное — прокси к API-серверу
- matches:
- path: { type: PathPrefix, value: / }
backendRefs:
- name: oidc-proxy
port: 443Обратите внимание на порядок: правило с префиксом / — catch-all, и в Gateway API оно не «съедает» более специфичные маршруты, поэтому /dex и /kubeconfig отрабатывают раньше. Это отличается от привычного поведения location в nginx, где порядок и модификаторы решают всё, — и на это стоит обратить внимание при миграции.
kubeconfig, который не жалко потерять
Пользователь заходит браузером на /kubeconfig или набирает в консоли curl -LOJ https://cluster-name-dex.multifactor.dev/kubeconfig, и получает готовый файл:
apiVersion: v1
kind: Config
current-context: cluster-name
clusters:
- name: cluster-name
cluster:
server: https://cluster-name-dex.multifactor.dev
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0t...
contexts:
- name: cluster-name
context:
cluster: cluster-name
user: oidc-user
users:
- name: oidc-user
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: kubectl
args:
- oidc-login
- get-token
- --oidc-issuer-url=https://cluster-name-dex.multifactor.dev/dex
- --oidc-client-id=kubernetes-cli
- --oidc-extra-scope=profile
- --oidc-extra-scope=email
- --oidc-extra-scope=groups
- --oidc-extra-scope=offline_accessЗдесь нет ни одного секрета. Ни сертификата пользователя, ни ключа, ни токена, ни пароля. Только публичный адрес, публичный CA и идентификатор публичного OIDC-клиента. Такой файл можно спокойно положить в общую вики, приложить к welcome-письму новому сотруднику или закоммитить в репозиторий с документацией — он бесполезен без учётной записи и второго фактора. Ключ --oidc-extra-scope=offline_access включает авто-ротацию токена, а если вы используете не самоподрисанный сертификат, то легко можете убоать и certificate-authority-data.
Работает это через exec-плагин oidc-login (проект kubelogin), который ставится, например, через krew:
brew install krew
kubectl krew install oidc-loginДальше — обычная работа. Первый kubectl get pods или коннект в Lens открывает браузер, пользователь логинится, подтверждает вход на телефоне, токен кэшируется локально (~/.kube/cache). Когда токен протухает, kubectl дёргает тот же exec-плагин: если сессия в Dex ещё жива — токен обновится незаметно, если нет — откроется браузер.
Отдельно скажем про сам генератор. Это ~60 строк на Python, монтируемые через ConfigMap, которые подставляют в шаблон имя кластера и CA-сертификат, взятый из того же wildcard-секрета:
with open('/etc/kubeconfig-generator/ca.crt', 'rb') as f:
ca_data = base64.b64encode(f.read()).decode()
cluster_name = os.getenv('CLUSTER_NAME', 'k8s-cluster')
kubeconfig = template.replace('{{ .CAData }}', ca_data)
kubeconfig = kubeconfig.replace('{{ .ClusterName }}', cluster_name)Можно ли было обойтись без своего сервиса? Да, но тогда его придётся руками пересобирать кубконфиг при каждой ротации сертификата. Сервис же собирает файл на лету из актуального секрета, поэтому скачанный вчера и скачанный сегодня конфиг всегда консистентны с кластером.
Подводные камни Gateway API
Здесь начинается самое интересное. Gateway API — это не «Ingress с другим синтаксисом», это другая модель с явным разделением ролей: инфраструктурная команда владеет Gateway, прикладная — своими HTTPRoute. Разделение полезное, но оно создаёт целый класс ошибок, которые не проявляются как ошибки.
1. Маршрут не подключился, и никто об этом не сказал
Наш HTTPRoute был в одном неймспейсе, Gateway — в другом. Всё задеплоилось без ошибок, ArgoCD зелёный, поды зеленые, а curl возвращает 404.
Причина в значении по умолчанию:
listeners:
- name: https
protocol: HTTPS
port: 443
# allowedRoutes не указан => namespaces.from: SameЕсли allowedRoutes не задан явно, слушатель принимает маршруты только из своего неймспейса. Диагноз виден исключительно в статусе самого маршрута:
Conditions:
Type: Accepted
Status: False
Reason: NotAllowedByListeners
Message: The Route is not allowed by any listenerЛечится одной строчкой на стороне Gateway:
allowedRoutes:
namespaces:
from: All # а лучше списком неймспейсовВывод: после каждого применения HTTPRoute смотрите на status.parents[].conditions. Зелёный ArgoCD здесь не значит ничего.
2. BackendTLSPolicy: обратной совместимости с привычками нет
Наш kube-oidc-proxy умеет только HTTPS — он API-сервер, открыть его по HTTP нельзя. Значит, шлюз должен расшифровать клиентский трафик и зашифровать его заново на пути к бэкенду.
Кто использовал Istio, знаком с DestinationRule:
# Istio: как это делалось раньше
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
spec:
host: oidc-proxy.dex.svc.cluster.local
trafficPolicy:
tls:
mode: SIMPLE
insecureSkipVerify: true # ← вот этоВ Gateway API аналога insecureSkipVerify нет, и это не упущение, а позиция авторов спецификации: BackendTLSPolicy существует именно для того, чтобы бэкенд-соединение было проверяемым. Отключить проверку нельзя — можно только указать, что именно проверять.
Мы на этом честно споткнулись. Первая версия политики выглядела логично:
validation:
hostname: oidc-proxy.dex.svc.cluster.local # внутреннее DNS-имя сервиса
wellKnownCACertificates: SystemИ давала 502 с таким сообщением в логах NGINX:
upstream SSL certificate does not match "oidc-proxy.dex.svc.cluster.local"
while SSL handshaking to upstream, upstream: "https://10.244.0.132:8443/version"Разгадка: под oidc-proxy монтирует тот же самый wildcard-сертификат Let's Encrypt (*.multifactor.dev). Никакого oidc-proxy.dex.svc.cluster.local в SAN этого сертификата, разумеется, нет и быть не может.
Правильно — проверять по имени, которое в сертификате действительно есть:
validation:
hostname: cluster-name-dex.multifactor.dev
wellKnownCACertificates: SystemВывод: hostname в BackendTLSPolicy — это не «адрес, куда идти» (туда шлюз попадёт по эндпоинтам сервиса), а «имя, которое обязано быть в сертификате бэкенда». Разница неочевидная, результат 502.
3. Issuer URL обязан совпадать
Тонкий момент, который ломает логику «внутри кластера ходим по внутренним адресам». В OIDC значение iss в токене должно точно совпадать с тем --oidc-issuer-url, который проверяет потребитель токена. Если Dex выписывает токены с iss: https://cluster-name-dex.multifactor.dev/dex, то kube-oidc-proxy обязан проверять ровно эту строку — подменить её на http://dex.dex.svc.cluster.local:5556 нельзя.
Но тогда под oidc-proxy, живущий внутри кластера, должен уметь достучаться до публичного имени. Гонять этот трафик через интернет и обратно — плохая идея. Мы решили это через hostAliases:
spec:
template:
spec:
hostAliases:
- ip: 123.123.123.123 # адрес LoadBalancer
hostnames:
- cluster-name-dex.multifactor.devПод резолвит публичное имя в LoadBalancer-адрес своей же ноды, трафик разворачивается локально (hairpin), а строка issuer остаётся корректной. Симптом, если этого не сделать, — под не выходит в Ready, в логах бесконечное:
oidc authenticator: initializing plugin: Get ".../.well-known/openid-configuration":
net/http: request canceled while waiting for connectionПереносимость: как не хардкодить кластер
Когда конфигурация заработала, мы привели её в состояние, пригодное для копирования на следующий кластер. Специфичные для кластера значения были вынесены в Kustomize-патч и ArgoCD-приложение.
Первое — один Kustomize-патч для обычных манифестов:
# patch.cluster-config.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: oidc-proxy
spec:
template:
spec:
hostAliases:
- ip: 123.123.123.123
hostnames:
- cluster-name-dex.multifactor.dev
containers:
- name: oidc-proxy
env:
- name: OIDC_ISSUER_URL
value: https://cluster-name-dex.multifactor.dev/dex
***Здесь пригодилось знание того, как Kustomize объединяет списки. Списки containers и env в схеме Kubernetes помечены как patchStrategy: merge с ключом name, поэтому патч дополняет переменные окружения, а не затирает их целиком. А вот hostAliases такого ключа не имеет и заменяется полностью — в нашем случае это как раз то, что нужно, но знать об этой разнице полезно.
Второе — значения Helm, до которых Kustomize не дотягивается (чарт подключён отдельным source в ArgoCD). Их вынесли в inline-блок самого Application, который ArgoCD применяет поверх файла значений:
helm:
releaseName: dex
valueFiles:
- $values/manifests/dex/values.yaml
values: |
config:
issuer: https://cluster-name-dex.multifactor.dev/dex
frontend:
issuerUrl: https://cluster-name-dex.multifactor.dev/dexВ результате values.yaml стал полностью переносимым, а всё, что описывает конкретный кластер, лежит в двух предсказуемых манифестах.
Почему kube-oidc-proxy, а не флаги API-сервера
Классический способ подружить Kubernetes с OIDC — прописать --oidc-issuer-url и соседние флаги прямо в kube-apiserver. Мы сознательно не пошли этим путём.
Во-первых, в Talos это изменение машинной конфигурации с перезапуском статик-пода — операция, которую не хочется выполнять ради добавления второго OIDC-клиента. Во-вторых, ошибка в этих флагах ломает вход в кластер целиком, включая тот самый админский доступ, которым эту ошибку надо чинить. В-третьих, флаги — это состояние узла, а мы хотели держать всё в Git.
kube-oidc-proxy решает это иначе: он валидирует токен сам и обращается к API-серверу от имени пользователя через механизм impersonation. Для этого ему нужны довольно специфичные права:
rules:
- apiGroups: [""]
resources: ["users", "groups", "serviceaccounts"]
verbs: ["impersonate"]
- apiGroups: ["authentication.k8s.io"]
resources: ["userextras/scopes", "tokenreviews"]
verbs: ["create", "impersonate"]Права широкие — это надо понимать и учитывать в модели угроз: компрометация пода oidc-proxy эквивалентна возможности действовать от имени любого пользователя. Взамен API-сервер остаётся нетронутым, а вся OIDC-конфигурация становится обычным Deployment, который откатывается git-revert'ом.

Что получилось в итоге
Схема работает так:
Инженер один раз ставит
kubectl krew install oidc-loginи скачиваетkubeconfigпо ссылке.При первой команде открывается браузер: инженер вводит корпоративный логин и подтверждает вход на телефоне.
Токен живёт своё время, обновляется автоматически или через повторный вход — работа не прерывается.
Права определяются группами в MULTIDIRECTORY; выдача и отзыв доступа — это добавление и удаление из группы.
В аудит-логе виден конкретный человек.
Всё описано в Git и раскатывается ArgoCD.
Из неочевидных выводов, которые мы вынесли:
Gateway API стоит внедрять, но статусы объектов надо читать всегда. Большинство ошибок в нём не приводят к падению — они приводят к тишине.
Accepted: Falseвstatus.parents[].conditions— единственный источник правды, зелёный сервис ничего не гарантирует.BackendTLSPolicyне заменяетinsecureSkipVerifyи не должен. Если бэкенд говорит по HTTPS, у него должен быть сертификат с правильным именем, а у политики — правильное ожидание.Вынос 2FA в службу каталогов радикально упрощает кластер. Kubernetes не должен ничего знать про второй фактор, push-уведомления и политики доступа — это ответственность службы каталогов, и там она реализуется одной настройкой.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.