# Архитектура решения: портал администрирования HAProxy + Django + AD ## Вариант развёртывания: одна ВМ, без контейнеризации ## 1. Назначение Единая точка входа для администраторов инфраструктуры: пользователь логинится своей доменной учётной записью, видит только те административные ресурсы, которые открыты его AD-группе, и одним кликом переходит в конкретную систему по адресу `https://///...`, не запоминая адреса и не имея прямого доступа к внутренним админкам, минуя портал. Этот документ описывает вариант развёртывания **без 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:///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`), но код валидации на стороне внутренних систем — задача их владельцев.