Перейти к содержанию

Периметр и TLS

Во всей установке наружу смотрит один контейнер — caddy (профиль edge). Он терминирует TLS, сам выпускает и продлевает сертификаты и раскладывает запросы по сервисам по префиксу пути. Статья — для инженера, который готовит Caddyfile установки и отвечает за сертификаты.

Как устроен периметр

flowchart LR
    client([Браузер, harness, runner]) -->|443| caddy[caddy]
    caddy -->|/iam/*, префикс срезается| iam[iam-service:8010]
    caddy -->|/auth/*, префикс сохраняется| kc[keycloak:8080]
    caddy -->|/platform/*, срезается| papi[platform-api:8000]
    caddy -->|/api/v1/*, /health/*, /docs, /openapi.json| cp[control-plane-api:8000]
    caddy -->|/notify/*, /support-demo/*| other[сервисы профилей]
    caddy -->|всё остальное| web[platform-web:3000]
  • Контейнер caddy публикует ${EDGE_HTTP_PORT:-80} и ${EDGE_HTTPS_PORT:-443}.
  • Остальные сервисы либо не публикуют порты вовсе (control-plane-worker, context-adapter, базы), либо публикуют их только на 127.0.0.1 хоста — для эксплуатационного доступа и make smoke.
  • memory-service в промышленном Caddyfile наружу не выводится: память доступна только ядру и сервисам во внутренней сети.
  • В сети compose у caddy есть alias ${TAIMEN_PUBLIC_HOST}. Контейнеры, которые ходят к IAM или Keycloak по публичному адресу (issuer должен совпадать с тем, что видит браузер), резолвят публичное имя прямо в caddy внутри сети — без выхода наружу и hairpin NAT.

Маршруты

Образец промышленного файла — deploy/staging/Caddyfile; локальный вариант без TLS — deploy/caddy/Caddyfile.local. Раскладка путей в них одинаковая.

Путь Upstream Префикс Профиль Назначение
/iam/* iam-service:8010 срезается (handle_path) core IAM для клиентов: JWKS, обмен/интроспекция/отзыв PAT, tokens/exchange, federation:*, SCIM; issuer ${TAIMEN_PUBLIC_URL}/iam. Административные пути — 404 (см. ниже)
/auth/* keycloak:8080 сохраняется (handle) platform Keycloak живёт под KC_HTTP_RELATIVE_PATH=/auth
/platform/* platform-api:8000 срезается, X-Forwarded-Prefix /platform platform Шлюз платформы (UVICORN_ROOT_PATH=/platform)
/api/v1/*, /health/*, /docs*, /redoc*, /openapi.json control-plane-api:8000 нет core Control Plane API, WebSocket-подписки идут тем же маршрутом. /metrics наружу не выводится
/notify/* notification-service:8000 срезается notify Сервис уведомлений: API и инбокс, вебхук бота Telegram (/notify/channels/telegram/webhook, проверяется секретом вебхука), точка приёма скилла notify.send@1
/support-demo/* support-bot:8000 срезается demo Демо, в промышленной установке не нужен
/guide/* guide:8080 срезается edge Это руководство: статический сайт MkDocs (guide/Dockerfile)
/ (всё прочее) platform-web:3000 нет platform Веб-консоль

Порядок блоков важен

Маршрут Control Plane объявлен именованным матчером @cp_api раньше общего handle корня. Если добавляете свои маршруты, объявляйте их до блока корня, иначе запрос уйдёт в platform-web.

Маршрут профиля, который не поднят, отвечает 502 — это ожидаемо. Лишние маршруты лучше убрать из файла установки.

Минимальный Caddyfile установки

platform.example.com {
    encode zstd gzip

    # Административные пути IAM на периметре не нужны: см. «Закрытие служебных путей»
    handle_path /iam/* {
        @iam_admin {
            path /api/v1/events /api/v1/events/* /api/v1/tenants /api/v1/tenants/*
            not path_regexp ^/api/v1/tenants/[^/]+/federation:(authenticate|exchange)$
        }
        respond @iam_admin 404
        request_header -X-IAM-Bootstrap-Token
        reverse_proxy iam-service:8010
    }

    handle /auth/* {
        reverse_proxy keycloak:8080 {
            header_up X-Forwarded-Host {host}
            header_up X-Forwarded-Proto {scheme}
        }
    }

    handle_path /platform/* {
        reverse_proxy platform-api:8000 {
            header_up X-Forwarded-Prefix /platform
        }
    }

    # /metrics намеренно не входит в список: см. «Закрытие служебных путей»
    @cp_api path /api/v1/* /health/* /docs /docs/* /redoc /redoc/* /openapi.json
    handle @cp_api {
        reverse_proxy control-plane-api:8000 {
            header_up Host {host}
            header_up X-Real-IP {remote_host}
        }
    }

    handle {
        reverse_proxy platform-web:3000 {
            header_up Host {host}
            header_up X-Real-IP {remote_host}
        }
    }

    log {
        output stderr
        format json
    }
}

Путь к файлу задаётся в .env переменной CADDYFILE. Если профиль platform не поднимается, уберите блоки /auth/*, /platform/* и корня.

TLS и сертификаты

Caddy включает automatic HTTPS для любого адреса сайта с доменным именем: получает сертификат у ACME-центра, продлевает его сам и редиректит http на https. Состояние (сертификаты, ключи, ACME-аккаунт) лежит в томе caddy_data (${COMPOSE_PROJECT_NAME}_caddy_data), конфигурация — в caddy_config.

Требования для выпуска

Условие Почему
A/AAAA-запись имени указывает на хост до первого запуска caddy ACME-проверка (HTTP-01 или TLS-ALPN-01) приходит на адрес из DNS
Порты 80 и 443 доступны из интернета Через них идут проверки
Перед хостом нет CDN/прокси, терминирующего TLS TLS-ALPN-проверка через чужой TLS не проходит
Том caddy_data сохраняется между пересозданиями контейнера Иначе каждый пересозданный контейнер выпускает сертификат заново

Не держите в Caddyfile имена, которые не указывают на хост

Каждое такое имя Caddy будет пытаться сертифицировать, проверка будет уходить на чужой адрес и падать. Серия неудачных проверок упирается в лимиты ACME-центра на неудачные валидации, и выпуск для этого имени блокируется на время (у Let's Encrypt — ответ 429). Добавляйте блок сайта только когда DNS уже переключён, а неготовые имена держите закомментированными.

Перенос на новый хост

  1. Остановите caddy на старом хосте.
  2. Скопируйте том caddy_data (см. Резервное копирование) на новый хост до первого запуска caddy там.
  3. Переключите DNS, поднимите caddy. Перенесённые сертификаты подхватываются без перевыпуска, продление продолжится на новом хосте.

Если перед Caddy нужен балансировщик или CDN

  • TLS-проверки через CDN не проходят; нужен DNS-01, а стандартный образ caddy:2-alpine DNS-провайдеров не содержит — потребуется собственная сборка Caddy с плагином провайдера.
  • Caddy по умолчанию не доверяет входящему X-Forwarded-For и ставит адрес соединения. За балансировщиком это будет адрес балансировщика: объявите его в глобальной опции servers { trusted_proxies static <cidr> }, иначе per-IP лимиты platform-api будут считать всех пользователей одним клиентом.

Закрытие служебных путей

/metrics

GET /metrics Control Plane не аутентифицирован. Он не раскрывает tenant'ов и задачи (метрики агрегатные), но раскрывает счётчики, пути и нагрузку. Поставляемые Caddyfile (deploy/staging/Caddyfile, deploy/caddy/Caddyfile.local) его на периметр не выводят: /metrics нет в матчере @cp_api. Снимайте метрики изнутри сети compose (control-plane-api:8000/metrics) или с хоста:

curl -s http://127.0.0.1:18000/metrics

Административная поверхность IAM

Административные операции IAM (tenants, principals, audiences, identity providers, выпуск и отзыв PAT, service accounts, журнал /api/v1/events) защищены только заголовком X-IAM-Bootstrap-Token. Отдельной административной роли нет, поэтому утечка или перебор токена — это захват всей identity. Поставляемые Caddyfile эту поверхность наружу не публикуют:

  • пути /api/v1/tenants, /api/v1/tenants/* и /api/v1/events отвечают на периметре 404. Исключение — federation:authenticate и federation:exchange: их вызывают клиенты с токеном внешнего провайдера;
  • второй рубеж — заголовок X-IAM-Bootstrap-Token срезается до прокси. Даже запрос, обошедший матчер, до административного эндпоинта не авторизуется.

Наружу остаётся то, что нужно клиентам: /.well-known/jwks.json, обмен, интроспекция и отзыв PAT (/api/v1/platform-access-tokens:*), /api/v1/tokens/exchange для service account'ов, federation:*, SCIM (/scim/v2/*, аутентифицируется токеном источника провижининга) и /healthz.

deploy/bootstrap.py и скрипты стенда (deploy/staging/core/issue-pats.sh) ходят в IAM по 127.0.0.1:${IAM_HOST_PORT:-18010} и периметр не используют. Если внешний инструмент вашей установки выполняет административные операции через публичный адрес, переведите его на внутренний адрес или SSH-туннель — через периметр такие запросы больше не проходят.

Консоль администратора Keycloak

Маршрут /auth/* открывает и /auth/admin/. Если администраторы Keycloak работают из известной сети, ограничьте этот путь матчером remote_ip или выполняйте администрирование через SSH-туннель к 127.0.0.1:18081.

Изменение Caddyfile без простоя

# 1. Проверить синтаксис новой версии
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile

# 2. Применить
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

Bind-mount файла держит inode

Caddyfile смонтирован в контейнер как файл. Команды, которые заменяют файл новым (mv new Caddyfile, многие редакторы с атомарной записью, sed -i), создают новый inode, а контейнер продолжает видеть старый — caddy reload перечитает прежнюю версию. Правьте файл на месте (cat new > Caddyfile) или пересоздайте контейнер: docker compose up -d --force-recreate caddy.

Проверить, что контейнер видит актуальный файл:

docker compose exec caddy cat /etc/caddy/Caddyfile | diff - /opt/taimen/Caddyfile && echo "совпадает"

Проверка периметра

# Сертификат и срок
echo | openssl s_client -connect platform.example.com:443 -servername platform.example.com 2>/dev/null \
  | openssl x509 -noout -subject -enddate

# Маршруты
curl -fsS https://platform.example.com/health/ready
curl -fsS https://platform.example.com/iam/healthz
curl -fsS https://platform.example.com/iam/.well-known/jwks.json
curl -s -o /dev/null -w '%{http_code}\n' https://platform.example.com/metrics   # не 200

# Снаружи не должно быть ничего, кроме 80/443
nmap -Pn -p 1-65535 platform.example.com

Типичные проблемы

Симптом Причина Решение
Браузер получает ошибку TLS, в логах caddy — challenge failed / 429 Имя не указывает на хост или порт 80/443 закрыт; превышены лимиты ACME Проверить dig, открыть порты, убрать неготовые имена; после 429 ждать окна лимита
После правки Caddyfile ничего не изменилось Файл заменён новым inode Записать на месте или up -d --force-recreate caddy
502 на /support-demo/* Профиль не поднят Поднять профиль или убрать маршрут
Запросы API попадают в веб-консоль (HTML вместо JSON) Свой маршрут объявлен после блока корня Перенести блок выше handle { … platform-web … }
Issuer в токенах Keycloak не совпадает с ожидаемым, platform-api отвечает 401 Контейнеры резолвят публичное имя мимо caddy Проверить TAIMEN_PUBLIC_HOST (alias сети) и KC_HOSTNAME
Все пользователи упираются в один лимит 429 Перед Caddy балансировщик, не объявленный в trusted_proxies Настроить trusted_proxies, см. выше

См. также