224 lines
17 KiB
Markdown
224 lines
17 KiB
Markdown
# Архитектура решения: портал администрирования HAProxy + Django + AD
|
||||
|
|
## Вариант развёртывания: одна ВМ, без контейнеризации
|
|||
|
|
|
|||
|
|
## 1. Назначение
|
|||
|
|
|
|||
|
|
Единая точка входа для администраторов инфраструктуры: пользователь логинится
|
|||
|
|
своей доменной учётной записью, видит только те административные ресурсы,
|
|||
|
|
которые открыты его AD-группе, и одним кликом переходит в конкретную
|
|||
|
|
систему по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`, не
|
|||
|
|
запоминая адреса и не имея прямого доступа к внутренним админкам, минуя
|
|||
|
|
портал.
|
|||
|
|
|
|||
|
|
Этот документ описывает вариант развёртывания **без Docker** — все
|
|||
|
|
компоненты ставятся как обычные пакеты дистрибутива и управляются через
|
|||
|
|
`systemd` на одной виртуальной машине. Это упрощает эксплуатацию в
|
|||
|
|
организациях, где контейнеризация не принята, и снимает зависимость от
|
|||
|
|
Docker-инфраструктуры.
|
|||
|
|
|
|||
|
|
## 2. Компоненты и то, как они запущены на ВМ
|
|||
|
|
|
|||
|
|
| Компонент | Как установлен | Как запущен | Слушает |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| **HAProxy** | пакет `haproxy` из репозитория дистрибутива | systemd-юнит `haproxy.service` (штатный, из пакета) | `0.0.0.0:80`, `0.0.0.0:443`, `127.0.0.1:8404` (статистика) |
|
|||
|
|
| **Django frontend (portal)** | Python venv в `/opt/admin-portal/venv`, код в `/opt/admin-portal/django_app` | Gunicorn под собственным systemd-юнитом `admin-portal.service`, от имени системного пользователя `adminportal` | `127.0.0.1:8000` |
|
|||
|
|
| **Active Directory** | внешняя система, ничего не устанавливаем | — | LDAP/LDAPS (389/636) на контроллере домена |
|
|||
|
|
| **PostgreSQL** | пакет `postgresql` | systemd-юнит `postgresql.service` (штатный) | `127.0.0.1:5432` |
|
|||
|
|
| **Redis** | пакет `redis-server` | systemd-юнит `redis-server.service` (штатный) | `127.0.0.1:6379` |
|
|||
|
|
| **Backend admin panels** | в решении — 2 демо-заглушки на Nginx; в проде — реальные системы | демо: `nginx.service`, отдельные `server`-блоки на `127.0.0.1:8081/8082`; прод: на других хостах сети | только внутри периметра, не публикуются наружу |
|
|||
|
|
|
|||
|
|
Все процессы, кроме HAProxy, слушают исключительно `127.0.0.1` — наружу
|
|||
|
|
из ВМ открыты только порты 80/443 (см. `ufw` в README, шаг 11). Это заменяет
|
|||
|
|
собой изоляцию, которую в контейнерном варианте давала Docker-сеть.
|
|||
|
|
|
|||
|
|
## 3. Общая схема
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
HTTPS
|
|||
|
|
Браузер администратора ────────────────► HAProxy :443 (systemd: haproxy)
|
|||
|
|
│
|
|||
|
|
TLS termination, ACL-роутинг по path, HSTS, rate-limit
|
|||
|
|
│
|
|||
|
|
┌──────────────────────────────┼───────────────────────────────┐
|
|||
|
|
│ путь НЕ ресурсный, │ путь ресурсный + нет │ путь ресурсный + есть
|
|||
|
|
│ или /login /static/... │ resource_token │ resource_token
|
|||
|
|
▼ ▼ ▼
|
|||
|
|
┌─────────────────────────────────────────┐ ┌──────────────────────┐
|
|||
|
|
│ Gunicorn 127.0.0.1:8000 (systemd: │ │ Nginx 127.0.0.1:808x │
|
|||
|
|
│ admin-portal.service) │ │ (демо) или реальная │
|
|||
|
|
│ - django-auth-ldap -> AD bind │ │ админка на др. хосте │
|
|||
|
|
│ - каталог ресурсов из PostgreSQL │ └──────────────────────┘
|
|||
|
|
│ - сессии в Redis │ ▲
|
|||
|
|
│ - выпуск JWT для перехода на ресурс │──── redirect + cookie ───┘
|
|||
|
|
└─────────────────────────────────────────┘ resource_token
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
PostgreSQL 127.0.0.1:5432 (ResourceGroup, Resource, AccessLog)
|
|||
|
|
Redis 127.0.0.1:6379 (сессии Django, кэш групп LDAP)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 4. Поток аутентификации
|
|||
|
|
|
|||
|
|
1. Браузер отправляет запрос на `https://fqdn/`.
|
|||
|
|
2. HAProxy (см. `haproxy.cfg`) не находит cookie `resource_token` на
|
|||
|
|
нересурсном пути → направляет на `backend be_django` (Gunicorn,
|
|||
|
|
`127.0.0.1:8000`).
|
|||
|
|
3. Django рендерит форму логина. Пользователь вводит `sAMAccountName` + пароль.
|
|||
|
|
4. `django-auth-ldap`:
|
|||
|
|
- выполняет bind сервисным аккаунтом (`AUTH_LDAP_BIND_DN` из
|
|||
|
|
`/etc/admin-portal/admin-portal.env`),
|
|||
|
|
- ищет пользователя по `AUTH_LDAP_USER_SEARCH` (`sAMAccountName=%(user)s`),
|
|||
|
|
- выполняет повторный bind уже от имени найденного DN с введённым паролем —
|
|||
|
|
это и есть проверка пароля,
|
|||
|
|
- при успехе подтягивает атрибуты (`givenName`, `sn`, `mail`) и список
|
|||
|
|
групп (`AUTH_LDAP_GROUP_SEARCH`), кладёт их в `request.user.ldap_user`.
|
|||
|
|
5. Django создаёт сессию в Redis (локальный, `127.0.0.1:6379`), выставляет
|
|||
|
|
cookie `sessionid`.
|
|||
|
|
6. Пользователь видит дашборд со списком `Resource`, чьи
|
|||
|
|
`ResourceGroup.ad_group_name` входят в набор групп пользователя из AD.
|
|||
|
|
|
|||
|
|
## 5. Поток перехода на ресурс
|
|||
|
|
|
|||
|
|
1. Клик по карточке ресурса → `GET /{group}/{resource}/`.
|
|||
|
|
2. Первый переход всегда обрабатывается Django (`open_resource` view),
|
|||
|
|
поскольку у HAProxy ещё нет cookie `resource_token` для этого пути —
|
|||
|
|
маршрут `is_resource_path !has_resource_token` в `haproxy.cfg` уводит
|
|||
|
|
запрос на `be_django`.
|
|||
|
|
3. `open_resource` view:
|
|||
|
|
- проверяет, что ресурс существует, активен и открыт AD-группе
|
|||
|
|
пользователя,
|
|||
|
|
- пишет запись в `AccessLog` (кто, когда, какой ресурс, IP — таблица в
|
|||
|
|
PostgreSQL),
|
|||
|
|
- выпускает JWT (`RESOURCE_TOKEN_SECRET`, TTL по умолчанию 60 секунд),
|
|||
|
|
- кладёт токен в cookie `resource_token`, ограниченную `path` конкретного
|
|||
|
|
ресурса,
|
|||
|
|
- делает 302 редирект на тот же путь.
|
|||
|
|
4. HAProxy на повторном запросе видит `cook(resource_token) -m found` →
|
|||
|
|
маршрутизация по `group_map.cfg` на нужный `backend` (демо-Nginx на
|
|||
|
|
`127.0.0.1:808x` или реальная система на другом хосте). Перед
|
|||
|
|
проксированием HAProxy снимает cookie в заголовок `X-Resource-Token` и
|
|||
|
|
удаляет исходный `Cookie` — backend никогда не видит `sessionid` портала.
|
|||
|
|
5. Backend admin panel (при наличии соответствующей интеграции) валидирует
|
|||
|
|
JWT тем же `RESOURCE_TOKEN_SECRET` и на основании `sub`
|
|||
|
|
логинит/находит пользователя в своей системе.
|
|||
|
|
|
|||
|
|
### Важно: токен — это разовый хэндшейк, а не сессия внутри backend'а
|
|||
|
|
|
|||
|
|
`resource_token` живёт всего 60 секунд и привязан к `path` конкретного
|
|||
|
|
ресурса. Backend admin panel, приняв и провалидировав токен один раз,
|
|||
|
|
должен завести собственную сессию для дальнейшей работы пользователя — так
|
|||
|
|
делает подавляющее большинство систем при интеграции с внешним SSO. Если
|
|||
|
|
полагаться на `resource_token` при каждом запросе, через 60 секунд любой
|
|||
|
|
запрос без свежего токена уйдёт обратно в Django на переоформление. При
|
|||
|
|
необходимости TTL можно увеличить через `RESOURCE_TOKEN_TTL_SECONDS` в
|
|||
|
|
`.env`, но это не заменяет полноценную сессию backend'а.
|
|||
|
|
|
|||
|
|
## 6. Варианты передачи identity в backend (сравнение)
|
|||
|
|
|
|||
|
|
| Вариант | Как работает | Когда применять |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **A. Доверенный заголовок** (`X-Remote-User`) | HAProxy/Django прокидывает имя пользователя как есть, backend доверяет заголовку | Полностью изолированный сегмент сети, backend не имеет других входов |
|
|||
|
|
| **B. Подписанный JWT** (реализовано в этом решении) | Django выпускает короткоживущий токен, backend проверяет подпись | Рекомендуемый вариант по умолчанию |
|
|||
|
|
| **C. Полноценный SSO (SAML/OIDC)** | Backend сам является Service Provider, IdP — AD FS/Keycloak | Backend уже умеет SAML/OIDC "из коробки" (Grafana, GitLab и т.п.) |
|
|||
|
|
|
|||
|
|
## 7. Модель данных
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
ResourceGroup
|
|||
|
|
ad_group_name (slug, unique) -- реальное имя группы AD, сегмент пути
|
|||
|
|
display_name (str)
|
|||
|
|
|
|||
|
|
Resource
|
|||
|
|
group -> ResourceGroup
|
|||
|
|
slug (str) -- второй сегмент пути
|
|||
|
|
name (str)
|
|||
|
|
description (text)
|
|||
|
|
is_active (bool)
|
|||
|
|
unique(group, slug)
|
|||
|
|
|
|||
|
|
AccessLog
|
|||
|
|
user -> auth.User (nullable)
|
|||
|
|
resource -> Resource (nullable)
|
|||
|
|
ip_address (inet)
|
|||
|
|
created_at (datetime, auto)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Хранится в PostgreSQL, установленном локально на ВМ (`/var/lib/postgresql`).
|
|||
|
|
|
|||
|
|
## 8. Наполнение и сопровождение каталога ресурсов
|
|||
|
|
|
|||
|
|
1. **Django admin** (`https://<FQDN>/django-admin/`) — для точечных правок.
|
|||
|
|
2. **Декларативный YAML + management-команда** `sync_resources`:
|
|||
|
|
```bash
|
|||
|
|
sudo -u adminportal bash -c \
|
|||
|
|
'set -a; source /etc/admin-portal/admin-portal.env; set +a; \
|
|||
|
|
/opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \
|
|||
|
|
sync_resources /opt/admin-portal/django_app/resources.example.yaml'
|
|||
|
|
```
|
|||
|
|
Команда идемпотентна, можно хранить YAML в git и гонять через CI/CD по SSH.
|
|||
|
|
|
|||
|
|
## 9. Безопасность
|
|||
|
|
|
|||
|
|
- TLS 1.2+/1.3, HSTS, современные cipher suites — терминируются на HAProxy;
|
|||
|
|
все внутренние компоненты общаются по `127.0.0.1` в открытом виде (трафик
|
|||
|
|
не покидает ВМ) либо по внутренней сети без выхода наружу.
|
|||
|
|
- Пароли пользователей не хранятся на портале — только bind-запрос к AD.
|
|||
|
|
- CSRF-защита на всех формах Django.
|
|||
|
|
- Сессионные cookie: `Secure`, `HttpOnly`, `SameSite=Lax`.
|
|||
|
|
- `resource_token` — отдельный секрет, TTL 60 секунд, `path`-scope,
|
|||
|
|
`HttpOnly`.
|
|||
|
|
- HAProxy снимает cookie сессии портала перед проксированием на backend.
|
|||
|
|
- Аудит: каждый переход на ресурс логируется в `AccessLog`.
|
|||
|
|
- Rate-limit на `/login/` через `stick-table` в HAProxy.
|
|||
|
|
- Файл `/etc/admin-portal/admin-portal.env` с секретами — права `640`,
|
|||
|
|
владелец `root:adminportal`, читать может только процесс приложения.
|
|||
|
|
- systemd-юнит `admin-portal.service` запущен от непривилегированного
|
|||
|
|
пользователя `adminportal` (`nologin`), с `NoNewPrivileges`, `PrivateTmp`,
|
|||
|
|
`ProtectSystem=full`.
|
|||
|
|
- `ufw` открывает наружу только 80/443; PostgreSQL, Redis, Gunicorn и
|
|||
|
|
демо-Nginx слушают исключительно loopback — недоступны из сети в принципе,
|
|||
|
|
даже без дополнительных правил фаервола.
|
|||
|
|
- Backend admin panels в проде должны быть в отдельном сетевом сегменте без
|
|||
|
|
прямого доступа из интернета — HAProxy как единственная точка входа
|
|||
|
|
снаружи.
|
|||
|
|
|
|||
|
|
## 10. Отказоустойчивость и масштабирование без Docker
|
|||
|
|
|
|||
|
|
Однонодовый вариант (этот пакет) подходит для старта и для организаций
|
|||
|
|
среднего размера. Пути роста при увеличении нагрузки:
|
|||
|
|
|
|||
|
|
- **HAProxy**: поднять второй экземпляр HAProxy на второй ВМ + `keepalived`
|
|||
|
|
(VRRP) для плавающего IP, либо использовать внешний L4-балансировщик перед
|
|||
|
|
парой ВМ с HAProxy.
|
|||
|
|
- **Django/Gunicorn**: поскольку сессии и кэш групп уже вынесены в Redis,
|
|||
|
|
можно поднять `admin-portal.service` на второй ВМ тем же способом
|
|||
|
|
(шаги 3–8 из README) и добавить в `backend be_django` вторую строку
|
|||
|
|
`server django2 10.0.0.11:8000 check` — балансировка без изменения
|
|||
|
|
остальной конфигурации.
|
|||
|
|
- **PostgreSQL**: для прод-контура — вынести на отдельную ВМ с репликой
|
|||
|
|
(потоковая репликация PostgreSQL), поменять `POSTGRES_HOST` в `.env` на
|
|||
|
|
обеих Django-нодах.
|
|||
|
|
- **Redis**: при росте нагрузки — Redis Sentinel (2–3 ВМ), либо вынос на
|
|||
|
|
отдельную ВМ без кластеризации, если нагрузка умеренная (сессии — лёгкая
|
|||
|
|
нагрузка для Redis).
|
|||
|
|
- **LDAP**: кэш групп на `AUTH_LDAP_CACHE_TIMEOUT` (по умолчанию 600 секунд)
|
|||
|
|
снижает нагрузку на контроллеры домена независимо от количества Django-нод.
|
|||
|
|
|
|||
|
|
## 11. Ограничения и что осознанно вынесено за рамки
|
|||
|
|
|
|||
|
|
- Установочный скрипт (`scripts/install.sh`) рассчитан на Ubuntu/Debian
|
|||
|
|
(`apt`); для RHEL/CentOS/Alma потребуется адаптировать имена пакетов
|
|||
|
|
(`dnf install haproxy nginx postgresql-server redis python3 ...`) и путь
|
|||
|
|
до `python3-venv` — сама архитектура и код Django не меняются.
|
|||
|
|
- TLS-сертификат от внешнего центра сертификации подкладывается вручную —
|
|||
|
|
автоматический выпуск/продление (например, через `certbot`) в рамках
|
|||
|
|
данного пакета не настраивается, так как процедура сильно зависит от
|
|||
|
|
того, публичный это FQDN или внутренний корпоративный CA.
|
|||
|
|
- Мультиузловая отказоустойчивость (второй HAProxy, реплика PostgreSQL,
|
|||
|
|
Redis Sentinel) описана на уровне принципов в разделе 10, но конкретные
|
|||
|
|
Ansible/Terraform-плейбуки под неё не входят в этот пакет — количество ВМ
|
|||
|
|
и топология сильно зависят от инфраструктуры конкретной организации.
|
|||
|
|
- Интеграция конкретных backend admin panel с валидацией JWT специфична для
|
|||
|
|
каждой системы; в решении заложен универсальный контракт (заголовок
|
|||
|
|
`X-Resource-Token`, алгоритм `HS256`, общий секрет `RESOURCE_TOKEN_SECRET`),
|
|||
|
|
но код валидации на стороне внутренних систем — задача их владельцев.
|