InquirerNorthern Mindanao on full alert for martial law protestsPunchOgun gov stood surety for Igboho’s return to bury mother – LawyerBollywood HungamaAnushka Sen joins Nykaa’s K-Beauty universe as the face of its Korean beauty categoryInquirer EntertainmentBTS, Cortis, Katseye expand nominations for MTV VMAs20 MinutenIst Granit Xhaka als Nati-Captain noch tragbar?UOLNovo ataque contra navio no Estreito de OrmuzColliderRussell Crowe's 3-Hour Historical Epic Officially Arrives on HBO MaxRTL BoulevardRoma uit De Bondgenoten ondergaat spoedoperatie: 'Schrikken toen ik op IC belandde'Observador DesportoPJ detém 3 suspeitos de traficar droga em entregas postaisSRF News– Manuela Schwesig beschert der SPD eine bemerkenswerte AufholjagdKhaosod EnglishPolice arrest Samui court escapee after six-day manhuntThe Sydney Morning HeraldBrownlow Medal 2026 LIVE updates: Brownlow count begins, banned Swan with an early vote; Butters, Merrett open up on trade period
The Daily Newsstand · Free, Always
Monday, September 21, 2026

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

Translate

У нас начали активно появляться кластеры на 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

Отдаёт готовый kubeconfig по HTTP

Отдельно отметим то, чего в списке нет: мы не трогали флаги 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'ом.

Что получилось в итоге

Схема работает так:

  1. Инженер один раз ставит kubectl krew install oidc-login и скачивает kubeconfig по ссылке.

  2. При первой команде открывается браузер: инженер вводит корпоративный логин и подтверждает вход на телефоне.

  3. Токен живёт своё время, обновляется автоматически или через повторный вход — работа не прерывается.

  4. Права определяются группами в MULTIDIRECTORY; выдача и отзыв доступа — это добавление и удаление из группы.

  5. В аудит-логе виден конкретный человек.

  6. Всё описано в Git и раскатывается ArgoCD.

Из неочевидных выводов, которые мы вынесли:

  • Gateway API стоит внедрять, но статусы объектов надо читать всегда. Большинство ошибок в нём не приводят к падению — они приводят к тишине. Accepted: False в status.parents[].conditions — единственный источник правды, зелёный сервис ничего не гарантирует.

  • BackendTLSPolicy не заменяет insecureSkipVerify и не должен. Если бэкенд говорит по HTTPS, у него должен быть сертификат с правильным именем, а у политики — правильное ожидание.

  • Вынос 2FA в службу каталогов радикально упрощает кластер. Kubernetes не должен ничего знать про второй фактор, push-уведомления и политики доступа — это ответственность службы каталогов, и там она реализуется одной настройкой.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.