Веб-консоль

В pg_doorman встроена операторская веб-консоль. Она работает на том же HTTP-сервере, что отдаёт Prometheus-метрики; собранный фронтенд лежит внутри бинарника. Запуск консоли не добавляет внешних зависимостей: один процесс, один бинарь, один TCP-порт.

Включение

Консоль настраивается в секции [web]. Старое имя секции [prometheus] тоже принимается как алиас.

[web]
enabled = true
host = "0.0.0.0"
port = 9127

# Операторская консоль (по умолчанию выключена)
ui = true
ui_anonymous = false
log_tap_max_entries = 8192

При web.ui = true и general.admin_password, равном пустой строке или литералу "admin", консоль на старте переходит в режим «только метрики». HTTP-сервер продолжает отдавать /metrics, но административные эндпоинты иначе оказались бы открыты любому. Задайте настоящий пароль до того, как включать ui = true. Срабатывание этой проверки видно в логе по строке web.ui = true ignored: admin_password is default/empty.

ПараметрОписаниеПо умолчанию
enabledЗапускать ли HTTP-сервер. /metrics работает независимо от ui.false
hostАдрес, на котором слушает HTTP-сервер."0.0.0.0"
portПорт HTTP-сервера.9127
uiОтдавать SPA по / и публичные API-эндпоинты.false
ui_anonymousПри true публичные API-эндпоинты принимают запросы без авторизации. См. Роли доступа.false
log_tap_max_entriesРазмер кольцевого буфера в памяти для /api/logs. 0 отключает эндпоинт.8192

URL-карта

URLТребуемая рольНазначение
/, /pools, любой путь вне APIнетОболочка приложения. Отдаётся анонимно даже при ui_anonymous = false, чтобы прямая ссылка не открывала системный диалог Basic-авторизации браузера до того, как появится форма входа React.
/assets/*нетХэшированные JS, CSS, шрифты и SVG. Cache-Control: public, max-age=31536000, immutable.
/metricsнетPrometheus exposition format. От ui не зависит.
GET /api/auth/configнетСообщает SPA, подключён ли SSO и какая роль у текущего запроса.
GET /api/version, /api/overview, /api/pools, /api/clients, /api/servers, /api/connections, /api/stats, /api/databases, /api/users, /api/auth_query, /api/config, /api/log_level, /api/pool_coordinator, /api/pool_scaling, /api/sockets, /api/prepared, /api/interner, /api/top/clients, /api/top/prepared, /api/apps, /api/eventsAnonymous, когда ui_anonymous = true, иначе SsoJSON только для чтения, повторяет формат SHOW <admin-команда>.
GET /api/logs, /api/prepared/text/{hash}, /api/interner/top, /api/top/queriesSsoЭндпоинты только для чтения с персональными данными. /api/logs подключает буфер логов на первом запросе и отключает его через 2 минуты простоя. /api/top/queries возвращает первые ~120 символов SQL-текста из кеша. Эти данные не вынесены в публичную поверхность, потому что превью могут содержать литералы и идентификаторы клиентов.
POST /api/admin/{reload,pause,resume,reconnect}AdminУправляющие операции администратора. Семантика та же, что и у admin-протокола через psql.

Роли доступа

Сервер на каждом запросе вычисляет одну из трёх ролей. Проверка работает на стороне сервера; SPA дублирует её на клиенте только для того, чтобы не показывать действия, недоступные текущему оператору.

РольКак запрос её получаетЧто роль даёт
AnonymousУчётных данных нет, [web].ui_anonymous = true.Публичные /api/* только для чтения из таблицы выше плюс /metrics. На пути с персональными данными и /api/admin/* возвращается 401.
SsoВалидный JWT в Authorization: Bearer, в cookie sso_access_token= или в query ?token=, который не попадает в группу администраторов.Все эндпоинты чтения, включая пути с персональными данными. На POST /api/admin/* отдаётся 403.
AdminЛибо корректная пара Basic из [general].admin_username / admin_password, либо валидный JWT, у которого значение [web].sso_groups_claim пересекается с [web].sso_admin_groups.Полный доступ, включая POST /api/admin/{reload,pause,resume,reconnect}.

Когда в одном запросе есть и Basic, и SSO-токен, приоритет у Basic. Корректный admin-пароль даёт Admin независимо от состояния SSO. Неверный Basic-пароль не блокирует SSO-ветку: SSO-источники всё равно проверяются, и валидный JWT даёт роль Sso (или Admin, если совпала группа администраторов). Это покрывает типичный случай: в localStorage лежит просроченный JWT рядом с рабочим Basic-паролем.

Basic-пароль сравнивается за постоянное время, чтобы по длительности сравнения нельзя было угадывать символы. JWT проверяются по публичному ключу из [web].sso_public_key_file; разобранный ключ кэшируется на время жизни процесса и перечитывается на RELOAD.

fetch-обёртка SPA шлёт Accept: application/json, и сервер на ней отдаёт чистый 401 без WWW-Authenticate: Basic. Без этого браузер закешировал бы то, что оператор ввёл в системном диалоге Basic, и подставлял этот пароль поверх формы входа React. Инструменты с Accept: */* (curl, gh) получают challenge как обычно.

401 Unauthorized отдаётся, когда учётных данных не пришло или ни один вариант не прошёл парсинг и валидацию. 403 Forbidden — когда данные валидны, но роли не хватает для пути; тело — {"error":"forbidden","message":"admin role required"}. SPA на 401 повторно открывает форму входа, на 403 показывает неблокирующий баннер «admin role required», не уводя на форму входа.

Настройка SSO

SSO опциональный. По умолчанию ([web].sso_enabled = false) сервер обслуживает только роли Anonymous и Admin через Basic. Чтобы подключить внешний SSO-прокси:

  1. Получите от SSO-провайдера публичный RSA-ключ, которым он подписывает JWT, и сохраните его в PEM-файле (например, /etc/pg_doorman/sso-public.pem). Для oauth2-proxy ключ извлекается из приватного: openssl rsa -in private.pem -pubout -out public.pem. Для Keycloak — см. Keycloak ниже.

  2. Добавьте SSO-поля в [web]:

    [web]
    enabled = true
    ui = true
    host = "127.0.0.1"
    port = 9127
    ui_anonymous = false
    
    sso_enabled = true
    sso_proxy_url = "https://sso.example.com/oauth2/start"
    sso_public_key_file = "/etc/pg_doorman/sso-public.pem"
    sso_audience = ["pg_doorman"]
    sso_allowed_users = ["*"]
    
  3. Перечитайте конфиг: kill -SIGHUP <pid> или psql -h <host> -p 6432 -U admin -d pgbouncer -c 'RELOAD'.

  4. Проверьте: curl http://<host>:9127/api/auth/config должен вернуть "sso_enabled":true и заданный sso_proxy_url.

ПолеНазначениеПо умолчанию
sso_enabledВключает SSO-ветку. Без неё JWT не валидируются.false
sso_proxy_urlURL, на который SPA уводит браузер по кнопке «Sign in via SSO». Бэкенд этот URL сам не вызывает.null
sso_public_key_fileПуть к PEM-файлу с публичным RSA-ключом. Читается на старте и при RELOAD.null
sso_audienceДопустимые значения claim aud. Токен принимается, если совпадает хотя бы одно. Обязательное поле при sso_enabled = true.[]
sso_allowed_usersAllowlist по claim preferred_username (или sub). ["*"] принимает любого. Иначе пропускаются только перечисленные имена.["*"]
sso_groups_claimИмя JWT-claim, в котором лежат группы пользователя. Читается вместе с sso_admin_groups."groups"
sso_admin_groupsГруппы, которые поднимают SSO-пользователя до Admin. Пустой список оставляет каждый SSO-логин на роли Sso только для чтения.[]
sso_require_httpsОтклонять Bearer/cookie/query SSO-credentials, пришедшие по plain HTTP. Запрос считается защищённым только если TCP-peer входит в trusted_proxies и прокси прислал X-Forwarded-Proto: https. По умолчанию выключено, чтобы SSO продолжал работать в схеме «TLS терминирует прокси → pg_doorman слушает HTTP во внутренней сети».false
trusted_proxiesCIDR доверенных обратных прокси для X-Forwarded-For, Forwarded и X-Forwarded-Proto. При пустом списке pg_doorman игнорирует эти заголовки и берёт адрес прямого TCP-пира. Если sso_require_https = true работает за прокси, который завершает TLS, добавьте CIDR этого прокси, чтобы доверять X-Forwarded-Proto: https. См. Журнал доступа.[]

Поднятие SSO-пользователя до Admin через claim с группами

По умолчанию SSO-логин получает роль Sso — доступ только для чтения к логам и SQL-текстам, но без POST /api/admin/*. Чтобы операторы могли запускать управляющие операции администратора через SSO без раздачи Basic-пароля, настройте sso_groups_claim и sso_admin_groups:

[web]
sso_enabled = true
sso_public_key_file = "/etc/pg_doorman/sso-public.pem"
sso_audience = ["pg_doorman"]
sso_groups_claim = "groups"
sso_admin_groups = ["pg-doorman-admins"]

Когда в валидном JWT приходит "groups": [..., "pg-doorman-admins"], запрос получает роль Admin. В access-логе это выглядит как auth_role=admin auth_source=sso, и SSO-админы по-прежнему отличимы от Basic-админов. /api/auth/config отдаёт sso_admin_groups_configured = true, и SPA убирает из формы входа обещание «SSO grants read-only access».

Keycloak

Keycloak подписывает каждый JWT RSA-ключом realm'а. Публичную часть этого ключа нужно один раз выгрузить в PEM-файл, который читает pg_doorman.

Без UI — через JWKS-эндпоинт realm'а:

REALM=https://kc.example.com/realms/operators
curl -s "$REALM/protocol/openid-connect/certs" \
  | jq -r '.keys[] | select(.alg=="RS256") | "-----BEGIN CERTIFICATE-----\n" + .x5c[0] + "\n-----END CERTIFICATE-----"' \
  | openssl x509 -pubkey -noout \
  > /etc/pg_doorman/sso-public.pem

Через админ-консоль: Realm settingsKeys → строка с Algorithm = RS256 и Use = SIGPublic key → скопированное base64-тело завернуть в PEM-файл с заголовками -----BEGIN PUBLIC KEY----- / -----END PUBLIC KEY-----.

Секция [web] под Keycloak выглядит так:

[web]
sso_enabled = true
sso_proxy_url = "https://kc.example.com/realms/operators/protocol/openid-connect/auth"
sso_public_key_file = "/etc/pg_doorman/sso-public.pem"
sso_audience = ["pg_doorman"]    # client_id, заданный в Keycloak
sso_groups_claim = "groups"      # значение по умолчанию для маппера «groups»
sso_admin_groups = ["pg-doorman-admins"]

Чтобы Admin через group claim работал, добавьте клиенту маппер Group Membership (Clients → нужный client → Mappers). Без этого маппера Keycloak выдаёт токены без groups, и каждый оператор остаётся в роли Sso.

После ротации ключа realm'а заново выгрузите PEM и сделайте RELOAD — pg_doorman подхватит новый ключ без рестарта.

Когда SSO-конфигурация сломана

Опечатка в SSO-секции не должна выводить операторскую консоль из строя. При sso_enabled = true, но не загружаемом рантайме (нет PEM-файла, пустой audience, нечитаемый PEM) сервер пишет причину в лог на уровне error, оставляет SSO выключенным на этот запуск и обслуживает только Basic и Anonymous. Та же причина видна в двух точках, чтобы оператор заметил поломку, а не тихий откат на Basic:

  • /api/auth/config.sso_config_error содержит человекочитаемое сообщение. SPA показывает баннер с этим текстом в форме входа.
  • Метрика pg_doorman_web_sso_config_error равна 1, пока SSO запрошен, но не загружен. В паре с pg_doorman_web_sso_enabled подходит для alert-правила.

Логин из браузера

При первом заходе SPA получает /api/auth/config и показывает форму входа. Если в ответе пришёл sso_proxy_url, рядом с Basic-формой появляется кнопка Sign in via SSO; иначе — только Basic.

Клик по Sign in via SSO уводит браузер на ${sso_proxy_url}?redirect_to=<текущий URL>. Внешний proxy выполняет OAuth/OIDC-обмен и возвращает браузер обратно с ?token=<jwt>. SPA сохраняет токен в localStorage, чистит URL от параметра и шлёт Authorization: Bearer <jwt> на каждом следующем запросе.

В нижней части боковой панели отображается имя текущего пользователя: admin для Basic или sso: <preferred_username> для SSO. Кнопка Sign out очищает в localStorage оба ключа (pgdoorman.admin-auth и pgdoorman.sso-token) и заново открывает форму входа.

Тихое обновление токена запускается раз в 60 секунд. Когда до exp остаётся меньше 90 секунд, SPA открывает скрытый iframe с URL ${origin}/?sso_silent=1. Внутри iframe запускается минимальный SilentCallback без обычных polling-эффектов. Он через window.postMessage отдаёт новый токен parent-окну. Если тихое обновление не сработало:

  • при наличии Basic-данных SPA удаляет SSO-токен без редиректа, и дальнейшие запросы идут под Basic;
  • иначе SPA уходит на полный редирект через SSO-proxy.

Срок жизни JWT задавайте не меньше 5 минут — более короткие токены успевают истечь до тихого обновления.

SPA cookie не шлёт (credentials: "omit" на каждом fetch). Путь с cookie sso_access_token существует для сайдкаров, curl и oauth2-proxy вариантов, которые кладут токен в cookie на общем домене.

Basic-пароль по умолчанию живёт только в памяти React и пропадает после полной перезагрузки страницы. Галочка Remember me on this device в форме входа сохраняет его в localStorage, поэтому консоль открывается без повторного ввода. Очистка хранилища сайта в браузере удаляет и Basic, и SSO-запись.

Журнал доступа

После каждого ответа (200/401/403/404/5xx, включая запросы к /metrics) консоль пишет одну logfmt-строку в канал pg_doorman::web::access:

INFO pg_doorman::web::access method=GET path=/api/admin/reload query=false status=200 bytes=42 latency_ms=12 peer=10.0.1.5:42312 auth_role=admin auth_source=basic auth_user=admin

Поля:

  • method, path — HTTP-метод и URL-путь. Тела запросов и ответов в лог не пишутся.
  • query=true|false — была ли в запросе строка параметров. Сама строка не логируется, чтобы JWT в ?token= не попадал в журнал.
  • status, bytes, latency_ms — статус ответа, размер тела и полная задержка ответа.
  • peer — адрес инициатора запроса. По умолчанию это непосредственный TCP-peer. Если он попадает в [web].trusted_proxies, сервер разбирает X-Forwarded-For (или Forwarded, RFC 7239), идёт по цепочке справа налево, пропускает доверенные адреса и берёт первый недоверенный. Недоверенный клиент не может подделать поле — если TCP-peer не в списке доверенных, заголовки прокси игнорируются.
  • auth_roleadmin, sso, anonymous или rejected.
  • auth_sourcebasic, sso или -.
  • auth_user — имя пользователя из учётных данных или - для анонимов и отклонённых запросов.

Уровни:

  • info — все управляющие действия (POST /api/admin/*), все чтения персональных данных (/api/logs, /api/prepared/text/*, /api/interner/top, /api/top/queries), все эндпоинты аутентификации и SSO (/api/auth/*, /api/sso/*) и все ответы с кодом, отличным от 2xx.
  • debug — любые остальные успешные чтения (2xx), анонимные или под авторизованной ролью. SPA опрашивает /api/overview, /api/pools, /api/clients, /api/process раз в 1,5–3 секунды; при старом правиле «любой 2xx с авторизацией = info» оператор на странице Logs видел собственные polling-запросы. Рутинные чтения логируются на debug, а RUST_LOG=info остаётся для управляющих действий, auth-трафика и ошибок.

Отдельный канал pg_doorman::web::access позволяет фильтровать поток журнала доступа независимо от остальных логов. В выпадающем фильтре на странице Logs этот канал включается или исключается одним кликом.

Реальный IP клиента за обратным прокси

По умолчанию peer фиксирует TCP-адрес, который соединился с сервером. За обратным прокси это адрес самого прокси. Чтобы видеть реальный IP клиента, добавьте CIDR прокси в [web].trusted_proxies:

[web]
trusted_proxies = ["10.0.0.0/8", "192.168.0.0/16"]

Распознаются и X-Forwarded-For, и Forwarded. Несколько доверенных переходов в цепочке пропускаются. X-Forwarded-For, пришедший от недоверенного клиента, игнорируется, поэтому через эту настройку произвольный вызывающий не может управлять полем access-лога.

Метрики

МетрикаТипЛейблыНазначение
pg_doorman_web_sso_enabledgauge1, когда SSO загружен успешно, иначе 0.
pg_doorman_web_sso_config_errorgauge1, когда sso_enabled = true, но рантайм не поднялся.
pg_doorman_web_auth_attempts_totalcounterrole, sourceПопытки авторизации в разрезе итоговой роли (admin/sso/anonymous/rejected) и источника (basic/sso/none).
pg_doorman_web_requests_totalcounterstatus_class, roleЗапросы к веб-консоли в разрезе HTTP-статуса (1xx5xx) и роли.
pg_doorman_web_sso_validation_errors_totalcounterreasonОтказы валидации JWT по причине: signature, expired, audience, no_username, allowlist.

Устойчивый рост signature означает, что SSO-прокси ротировал ключ, а sso_public_key_file остался старый. Рост allowlist — кто-то снаружи sso_allowed_users упорно пытается войти. Рост 4xx для роли sso обычно указывает на сломанный прокси перед pg_doorman.

Диагностика

401 на JWT, который должен быть валиден. Проверьте, что aud совпадает хотя бы с одним значением из sso_audience и exp ещё не истёк. PEM проверяется через openssl rsa -pubin -in <pem> -text -noout. Счётчик pg_doorman_web_sso_validation_errors_total{reason} показывает, какая именно проверка не прошла.

403 на JWT, который должен быть валиден. Путь требует роли Admin (например, POST /api/admin/reload). Войдите по Basic admin-паролю или добавьте группу пользователя в [web].sso_admin_groups и перечитайте конфиг.

SPA не показывает Sign in via SSO. /api/auth/config не возвращает sso_proxy_url. Либо [web].sso_enabled = false, либо sso_proxy_url не задан, либо рантайм не поднялся (ищите sso_config_error в том же ответе).

Тихое обновление токена не срабатывает. SSO-прокси должен возвращать свежий токен без полного экрана логина, когда iframe приходит с активной сессией. У oauth2-proxy это включается флагом --silent-refresh=true.

JWT в cookie игнорируется. Cookie должна попасть на pg_doorman с того же домена, и aud обязан входить в sso_audience. SPA сама cookie не шлёт; cookie-аутентификация рассчитана на curl, сайдкары и oauth2-proxy-варианты, которые проставляют токен в cookie на общем домене.

Страницы

В боковой панели восемь пунктов. Экран инцидента открывается из Обзора. Страницы с SQL-текстом и логами требуют роли Sso или Admin; анонимные пользователи их не видят.

Обзор (/overview)

Страница по умолчанию. Показывает состояние всего процесса, основные метрики, очереди, насыщение пулов, частые SQLSTATE и свёрнутый блок ресурсов. Если пул ушёл на резервный бэкенд через Patroni, сверху появляется баннер со списком затронутых пулов.

Пулы (/pools)

Таблица всех пулов user@database: размер, активные соединения, ожидание, p95, ошибки, насыщение и флаг резервного бэкенда. Строка открывает Детали пула.

Детали пула (/pools/:poolId)

Подробности одного пула: режим, лимиты, текущие соединения, TLS, резервный бэкенд, SQLSTATE, параметры запуска PostgreSQL и причины сработавших порогов. Здесь же находятся действия PAUSE, RESUME, RECONNECT и общий RELOAD.

Клиенты (/clients)

Таблица клиентов с фильтрами в URL:

/clients?pool=shop_checkout&state=waiting&user=app

Фильтры: pool, database, user, state, application_name, peer-адрес. Сортировка: запросы, ошибки, возраст соединения и возраст текущего запроса. Вместе со страницей Серверы помогает связать клиента с PostgreSQL pid.

Серверы (/servers)

Таблица бэкенд-соединений из SHOW SERVERS: server_id, process_id, база, пользователь, приложение, состояние, возраст активного запроса, счётчики запросов и ошибок, трафик и TLS. Используйте server_id из строки клиента, чтобы найти pid в pg_stat_activity.

Приложения (/apps)

Одна строка на каждый application_name: активные клиенты, qps, tps, суммарные запросы, транзакции, ошибки и err / 1k q.

Кеши (/caches)

Две вкладки: кеш подготовленных запросов по пулам и кеш SQL-текста в процессе. Обе могут показывать SQL, поэтому доступны только Sso и Admin.

Логи (/logs)

Лента LogTap с фильтрами в URL:

/logs?level=ERROR&q=53300

Пауза останавливает только отображение; серверный буфер продолжает заполняться. При [web].log_tap_max_entries = 0 страница показывает, что поток логов выключен. Доступ: Sso / Admin.

Конфиг и состояние (/config)

Зеркало команд SHOW CONFIG, SHOW DATABASES, SHOW USERS, SHOW AUTH_QUERY, SHOW LOG_LEVEL, SHOW STARTUP_PARAMETERS, SHOW SOCKETS, SHOW POOL_SCALING, SHOW POOL_COORDINATOR. Страница помогает проверить текущую конфигурацию и увидеть, какие ключи применяются на RELOAD, а какие требуют рестарта. Кнопка Reload config доступна только Admin.

Экран инцидента (/wall)

Версия Обзора для большого экрана: карта насыщения пулов, крупные метрики и последние управляющие действия. По Esc возвращает на /overview.

Управляющие действия

Четыре управляющие команды доступны в SPA:

ДействиеОбластьГде найтиПодтверждение
RELOADвсе пулыКонфиг и состояние · Детали пулаRELOAD
PAUSEодин user@databaseДетали пулаимя базы
RESUMEодин user@databaseДетали пула, когда пул паузированимя базы
RECONNECTодин user@databaseДетали пулаимя базы

Семантика та же, что в admin-протоколе через psql. PAUSE прекращает выдачу бэкендов в нужном пуле; уже идущие транзакции продолжают выполняться. RESUME снова разрешает выдачу. RECONNECT закрывает idle-бэкенды и отказывает активным при возврате. RELOAD перечитывает pg_doorman.toml; размер пула уменьшается по мере освобождения соединений.

Подтверждение вводом защищает от случайного RELOAD или PAUSE не того пула. Результат показывается уведомлением, а само действие пишется в access-лог и список последних событий администратора.

Горячие клавиши

Сочетания работают вне текстовых полей.

СочетаниеДействие
⌘ K / Ctrl KПоиск по страницам и пулам.
?Список горячих клавиш.
EscЗакрыть подсказку или окно. На /wall возвращает на Обзор.

Тема

Внизу боковой панели есть переключатель Light / System / Dark. По умолчанию используется Light. Выбор хранится в localStorage.

Встроенная справка

Рядом с заголовками метрик и секций есть значок (i). Подсказка объясняет, что означает число, откуда оно взято, как считается и какие пороги считаются нормой.

Сборка из исходников

Собранный веб-интерфейс лежит в git по пути frontend/dist/, чтобы RPM-, DEB- и Docker-сборки не зависели от Node.js. Разработчикам, правящим веб-интерфейс, нужно пересобирать его перед коммитом:

cd frontend
npm ci
npm run install-hooks   # одноразово: ставит pre-commit hook для синхронизации dist
npm run lint
npm run typecheck
npm run build

npm run install-hooks опционален. CI его не требует: проверка .github/workflows/frontend.yml запускает npm run check-dist и блокирует слияние, если исходники меняли без пересборки dist/. Она же запускает lint и typecheck на каждом PR, который трогает frontend/.

Развёртывание

/metrics доступен без авторизации на том же HTTP-сервере, что и консоль. Так задумано: иначе сломались бы существующие настройки Prometheus. Авторизация на /api/* не распространяется на /metrics — метрики раскрывают имена пулов, пользователей и БД, давление на пул, состояние auth_query и форму нагрузки. Либо держите секцию [web] на приватном host:port, доступном только системе сбора метрик, либо ставьте перед HTTP-сервером прокси, который добавляет авторизацию на /metrics отдельно.